Web development basics · Lesson 7 of 11 · about 40 minutes

Login with Supabase

The bakery takes pre-orders, so customers need accounts, and each must see only their own orders. Supabase handles the logins; row level security in the database decides who sees which rows. Tested step by step on a local Supabase: with security off a stranger read and changed every order, and an edited login cookie fooled one of the two ways a server can check who's signed in.

By the end you'll know

  • What Supabase's publishable and secret keys are, and which one may go in the browser
  • What row level security (RLS) is, and how to turn it on and write policies
  • Why "no policy" means "nobody", even for a row's owner
  • How a login works in Next.js: a server action, a cookie and a proxy
  • Why getClaims(), not getSession(), is the way to check who's signed in on the server
  • What the browser can and can't be trusted with

Before you start

  • You need: Lessons 4 and 6: the bakery's Next.js app, server components, and keeping keys on the server. Node.js 22 or later.
  • Versions: Supabase CLI 3.0.0, @supabase/supabase-js 2.119.0, @supabase/ssr 0.12.7 and Next.js 16.4.5, November 2026. The local stack ran Postgres 17.11, PostgREST 16.5 and Supabase Auth 2.197.0.
  • The example: Juniper & Rye, the made-up bakery, now taking pre-orders from two made-up customers, Ada and Ben. Everything runs on your machine, on a throwaway local Supabase; no account or real project is needed. Every run and line of output below is real.

The short version

  • The publishable key is public. It's in your page's JavaScript, so anyone can use it. It can do only what your database's rules allow.
  • Turn on row level security for every table the browser can reach. With it off, a stranger with no account read every order and moved one to Christmas.
  • Policies say who may do what to which rows. "Only the owner reads it" and "only in your own name" took two short SQL statements. No policy for an action means nobody can do it.
  • The secret key bypasses all of it. It belongs only on a server, never in a NEXT_PUBLIC_ variable.
  • On the server, check logins with getClaims(). A login cookie edited to say "Ben" fooled getSession(); getClaims() rejected it.

The plan

Juniper & Rye wants customers to reserve loaves for pickup. That needs three things the site doesn't have yet: accounts, a table of pre-orders, and a rule that each customer sees only their own.

Supabase provides all three on top of a Postgres database:

  • Supabase Auth stores accounts, checks passwords, and gives each signed-in person a token: a small signed message saying who they are.
  • An API in front of the database (PostgREST), so that code, including code running in a browser, can read and write tables over HTTP.
  • Row level security (RLS), a Postgres feature that checks every row against rules you write, using the token to know who's asking.

The browser can talk to the database directly. That's convenient, and it's why this lesson is about trust: everything the browser has, the visitor has too.

A local Supabase

The practice folder, lesson-07, has the scripts for this lesson and a small Next.js app in site/. Its package.json pins the Supabase command line tool (CLI) and client libraries.

(Your machine)

BASH
cd lesson-07
npm install
SUPABASE_EXPERIMENTAL_STACK=1 npx supabase init
npx supabase start -x realtime,storage,functions,studio,mail,analytics,pooler
  • npm install installs the CLI and the two client libraries into the folder, so npx supabase runs version 3.0.0.
  • supabase init creates supabase/config.toml, the local project's settings. SUPABASE_EXPERIMENTAL_STACK=1 turns on CLI 3's new local stack, which can run with Docker if it's running, or natively without Docker on macOS (Apple silicon) and Linux. It's marked experimental: fine for practice, not something to build a team's workflow on yet.
  • supabase start downloads and starts the local services. -x leaves out the ones this lesson doesn't use (file storage, realtime updates, the admin dashboard and so on). That kept the download to 438 MB: the database, the API and Auth.

It ends with a table of the services it started, shown here as the terminal printed it, minus the box lines:

ServiceState
databaserunning · healthy · eager
restsleeping · starts on first request
authsleeping · starts on first request

Then two last lines:

TEXT
Runtime: native
Run supabase status --env to export these values as environment variables.
  • database is Postgres, started straight away (eager).
  • rest is the API in front of the database, and auth is Supabase Auth. Both start on the first request that needs them, so a stack you aren't using costs almost nothing.

Above that, it prints the local URL and two keys. The scripts read them with supabase status, so you never paste them anywhere. This run used the native runtime; with Docker, you'll see docker there instead. Your port numbers will differ from the ones below.

Two keys, two jobs

  • Publishable key (starts sb_publishable_): goes in the browser. It identifies your project and can do only what your RLS policies allow, like the old anon key.
  • Secret key (starts sb_secret_): goes only on servers you control. It bypasses RLS completely, like the old service_role key.

Supabase introduced these in place of the anon and service_role keys, and says it will deprecate the old ones by the end of 2026. If you have an older project, its migration guide (in Go deeper) covers the switch.

A table and two customers

The first version of the table, steps/01-table.sql:

SQL
-- Juniper & Rye pre-orders: one row per loaf someone reserved.
create table public.preorders (
  id       bigint generated always as identity primary key,
  user_id  uuid not null default auth.uid() references auth.users (id),
  item     text not null,
  pickup   date not null
);
  • id ... generated always as identity numbers the rows 1, 2, 3 and so on.
  • user_id ... default auth.uid() records who made the order. auth.uid() is a Supabase function that returns the signed-in user's id from their token, so the browser doesn't need to send it. references auth.users (id) means it must be a real account.
  • public is the schema (a folder of tables) that Supabase's API exposes by default.

Changes to a database are kept as migrations: numbered SQL files in supabase/migrations/, applied in order, so every copy of the database (yours, a teammate's, production) ends up the same.

(Your machine)

BASH
mkdir -p supabase/migrations
cp steps/01-table.sql supabase/migrations/20261107000001_preorders.sql
npx supabase migration up

The first two lines make the folder and add the file, named with a timestamp so migrations sort in order. migration up applies any that the local database hasn't run yet:

TEXT
Target: local stack (127.0.0.1:25892)
Applying migration 20261107000001_preorders.sql...
Local database is up to date.

Then seed.mjs signs up Ada and Ben and has each reserve loaves, the way the bakery's site would: from the browser, with the publishable key and their own login.

(Your machine)

BASH
node seed.mjs
TEXT
signed up ada@juniper.test
  reserved 2
signed up ben@juniper.test
  reserved 1

The heart of it, for each person:

JS
const c = browserClient();                    // URL + publishable key, like a page
await c.auth.signUp({ email, password: "rye-bread-42" });
await c.from("preorders").insert({ item, pickup });

browserClient() (in lib.mjs) creates a Supabase client with only the two things a browser gets: the project's URL and the publishable key. signUp creates the account and signs in. insert adds a row; user_id fills itself in from the login. Local Supabase skips email confirmation, so sign-up works straight away.

What a stranger can do

peek.mjs plays a stranger: no account, just the URL and publishable key, which anyone can copy from a page's JavaScript.

JS
const stranger = browserClient();
show("Stranger reads preorders:", await stranger.from("preorders").select().order("id"));

const r = await stranger.from("preorders").update({ pickup: "2026-12-25" }).eq("item", "Cardamom bun x6").select();
show("Stranger moves Ben's pickup to Christmas:", r);

The first query asks for every row; the second changes the pickup date on Ben's order. show prints rows as id, owner, item and date (it looks up the owner's name to make the output readable).

(Your machine)

BASH
node peek.mjs
TEXT
Stranger reads preorders:
  #1 ada   Dark rye, 2026-11-14
  #2 ada   Seeded sourdough, 2026-11-14
  #3 ben   Cardamom bun x6, 2026-11-15
Stranger moves Ben's pickup to Christmas:
  #3 ben   Cardamom bun x6, 2026-12-25

Everyone's orders, and an edit, with no login. Nothing unusual was done: this is what a new table looked like. Checking the database's grants showed the anon role (anyone with the publishable key) and the authenticated role (anyone signed in) with full read and write access, deleting included. RLS is left to you.

Supabase's database checker finds this. It's called the advisor:

(Your machine)

BASH
npx supabase db advisors --type security --fail-on error 2>/dev/null | grep -o '"detail": *"[^"]*"'; echo "exit code: ${PIPESTATUS[0]}"
  • db advisors --type security runs Supabase's security checks against the local database and prints the results as JSON.
  • --fail-on error makes it exit with code 1 if anything is at the error level, which is what you want in a CI pipeline.
  • 2>/dev/null | grep -o ... hides the Target: line and keeps only each problem's description. ${PIPESTATUS[0]} is the advisor's exit code, not grep's.
TEXT
"detail": "Table `public.preorders` is public, but RLS has not been enabled."
exit code: 1

Turning on row level security

steps/02-rls-on.sql is one line (plus a comment):

SQL
alter table public.preorders enable row level security;

(Your machine)

BASH
cp steps/02-rls-on.sql supabase/migrations/20261107000002_rls_on.sql
npx supabase migration up
node mine.mjs
node peek.mjs

mine.mjs signs in as Ada, reads, tries the same as a stranger, and has Ada reserve a baguette:

JS
const ada = await signedIn("ada@juniper.test");
show("Ada reads preorders:", await ada.from("preorders").select().order("id"));
show("Stranger reads preorders:", await browserClient().from("preorders").select().order("id"));
show("Ada reserves a baguette:", await ada.from("preorders").insert({ item: "Baguette", pickup: "2026-11-14" }).select());
TEXT
Ada reads preorders:
  (no rows)
Stranger reads preorders:
  (no rows)
Ada reserves a baguette:
  error: new row violates row-level security policy for table "preorders"
Stranger reads preorders:
  (no rows)
Stranger moves Ben's pickup to Christmas:
  (no rows)

(The last two lines come from peek.mjs.) With RLS on and no policies, nobody gets anything, not even Ada her own orders. That's the safe default: RLS starts by denying everything, and policies open specific doors.

Notice the difference between reading and writing. A blocked insert is an error. Blocked reads and updates aren't: they quietly match zero rows. The stranger's update "succeeded" and changed nothing. Ben's pickup is still Christmas, though, from before RLS was on; turning it on doesn't undo what already happened.

The advisor has moved on to a quieter note, at the info level:

(Your machine)

BASH
npx supabase db advisors --type security --level info 2>/dev/null | grep -o '"detail": *"[^"]*"'
TEXT
"detail": "Table `public.preorders` has RLS enabled, but no policies exist"

Policies: who owns each row

steps/03-policies.sql:

SQL
-- Signed-in customers see only their own pre-orders.
create policy "read own preorders" on public.preorders
  for select to authenticated
  using ((select auth.uid()) = user_id);

-- They can only add pre-orders in their own name.
create policy "add own preorders" on public.preorders
  for insert to authenticated
  with check ((select auth.uid()) = user_id);
  • create policy "..." on public.preorders adds a named rule to the table.
  • for select and for insert: a policy covers one kind of action (or all). There's no policy for update or delete, so nobody can do those.
  • to authenticated: it applies only to signed-in users. Strangers (anon) get no policy, so nothing.
  • using (...) is checked against existing rows: a row is visible only if the condition is true for it. Here, if its user_id is the signed-in user's.
  • with check (...) is checked against new rows: an insert is refused unless the condition is true.
  • (select auth.uid()), in brackets with select, lets Postgres work out the user's id once per query instead of once per row. Same result, faster on big tables; Supabase recommends writing it this way.

(Your machine)

BASH
cp steps/03-policies.sql supabase/migrations/20261107000003_policies.sql
npx supabase migration up
node mine.mjs
node peek.mjs
TEXT
Ada reads preorders:
  #1 ada   Dark rye, 2026-11-14
  #2 ada   Seeded sourdough, 2026-11-14
Stranger reads preorders:
  (no rows)
Ada reserves a baguette:
  #5 ada   Baguette, 2026-11-14
Stranger reads preorders:
  (no rows)
Stranger moves Ben's pickup to Christmas:
  (no rows)

Ada sees her two orders and not Ben's; the stranger sees nothing. The baguette is number 5, not 4, because the refused insert in the last step had already used up number 4. Identity numbers aren't reused, so gaps are normal.

Trying to break it

attack.mjs has Ada, signed in through the browser client, try four ways to reach past her own rows:

JS
show("1. Ada inserts an order in Ben's name:",
  await ada.from("preorders").insert({ item: "50 croissants", pickup: "2026-11-14", user_id: benId }).select());

show("2. Ada changes Ben's order:",
  await ada.from("preorders").update({ item: "Nothing" }).eq("user_id", benId).select());

show("3. Ada changes her own order:",
  await ada.from("preorders").update({ pickup: "2026-11-21" }).eq("item", "Dark rye").select());

The fourth takes Ada's token, rewrites the user id inside it to Ben's, and sends it straight to the API with fetch. A token is three parts separated by dots: a header, the claims (who it's for, when it expires) and a signature over both. The claims are just base64-encoded JSON, so anyone can decode and change them:

JS
const [head, body, sig] = data.session.access_token.split(".");
const claims = JSON.parse(Buffer.from(body, "base64url"));
claims.sub = benId;
const forged = [head, Buffer.from(JSON.stringify(claims)).toString("base64url"), sig].join(".");

(Your machine)

BASH
node attack.mjs
TEXT
1. Ada inserts an order in Ben's name:
  error: new row violates row-level security policy for table "preorders"
2. Ada changes Ben's order:
  (no rows)
3. Ada changes her own order:
  (no rows)
4. Ada's token is signed with ES256; she rewrites sub to Ben's id
  HTTP 401: No suitable key or wrong key type
  1. with check refused the forged owner. Without it, the default auth.uid() would only be a default; the browser could send any user_id it liked.
  2. Ben's order was untouched, silently.
  3. Ada couldn't change her own order either. There's no update policy. If customers should be able to change a pickup date, that needs its own policy, with both using (which rows) and with check (what they may become), or Ada could move her order onto Ben's account.
  4. The edited token was rejected. Supabase Auth signed it with a private key (ES256 is the signing method). Changing one character of the claims breaks the signature, and only Auth holds the private key to make a new one.

The secret key

Some jobs need to see every row: the bakery's morning list of what to bake, for example. That runs on a server with the secret key. server.mjs:

JS
const admin = createClient(URL, SECRET, { auth: { persistSession: false } });
show("Server with the secret key reads preorders:", await admin.from("preorders").select().order("id"));

It then sends the same key in a raw request with a browser's User-Agent header, because Supabase's documentation says secret keys used from a browser are rejected.

(Your machine)

BASH
node server.mjs
TEXT
Server with the secret key reads preorders:
  #1 ada   Dark rye, 2026-11-14
  #2 ada   Seeded sourdough, 2026-11-14
  #3 ben   Cardamom bun x6, 2026-12-25
  #5 ada   Baguette, 2026-11-14
Secret key sent with a browser's User-Agent: HTTP 200
  4 rows returned

Every row, policies ignored. That's its job, and why it must never leave the server. The second result is a useful surprise: the local stack accepted the secret key from a "browser". The browser check described in the documentation is something the hosted platform does; locally, nothing stopped it. And a User-Agent header is just text the caller chooses, so even where the check exists, it catches mistakes, not attackers. Treat it as a seatbelt, and keep the key off the browser yourself: in a server-only module, from an environment variable without the NEXT_PUBLIC_ prefix (Lesson 6).

Logging in with Next.js

So far, scripts played the browser. The bakery's real site is a Next.js app, and there the usual pattern is to sign in on the server: the form posts to a server action, the server talks to Supabase Auth, and the login is kept in a cookie that the browser sends with every request.

The app in site/ has four pieces.

lib/supabase/server.js, a Supabase client for server code that reads and writes the login cookie:

JS
import "server-only";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createClient() {
  const cookieStore = await cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll(list) {
          try {
            list.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
          } catch {
            // Server components can't set cookies; the proxy refreshes them instead.
          }
        },
      },
    }
  );
}
  • @supabase/ssr is Supabase's helper for server-rendered apps. Its job is to keep the login in cookies instead of in the browser's storage, so the server can see it.
  • cookies() is Next.js's access to the request's cookies. getAll and setAll are how the Supabase client reads the login and writes a new one.
  • The try/catch: a server component can read cookies but not set them. If the token needs refreshing during a page render, the proxy (below) does it.
  • Both variables are NEXT_PUBLIC_, which is fine: the URL and the publishable key are public by design. No secret key appears anywhere in the app.

app/login/actions.js, the server action the form submits to:

JS
"use server";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";

export async function login(formData) {
  const supabase = await createClient();
  const { error } = await supabase.auth.signInWithPassword({
    email: String(formData.get("email")),
    password: String(formData.get("password")),
  });
  if (error) redirect("/login?error=1");
  redirect("/orders");
}

"use server" makes these functions callable from a form; they run on the server. signInWithPassword asks Supabase Auth to check the password. If it's right, the client writes the session into a cookie through setAll, and the person is sent to their orders. If it's wrong, they go back to the form with ?error=1, which the login page turns into a message. (The same file has a logout action that calls signOut().)

app/orders/page.js, the protected page:

JS
const supabase = await createClient();

// getClaims() checks the token's signature. Don't use getSession() for this.
const { data } = await supabase.auth.getClaims();
if (!data?.claims) redirect("/login");

// Same publishable key as the browser, sent with this user's token,
// so row level security decides which rows come back.
const { data: orders, error } = await supabase
  .from("preorders")
  .select("id, item, pickup")
  .order("id");

The page has no where user_id = .... It doesn't need one: the query carries Ada's token, so the database's policies return only her rows. If the page had a bug, or someone copied this query into a page that forgot the login check, RLS would still hold.

proxy.js and lib/supabase/proxy.js: Next.js 16 runs proxy.js before every matching request (in Next.js 15 and earlier the same file was middleware.js). Here it refreshes the login cookie when the token is close to expiring (tokens last an hour by default), and sends signed-out visitors from /orders to /login:

JS
const { data } = await supabase.auth.getClaims();
if (!data?.claims && request.nextUrl.pathname.startsWith("/orders")) {
  const url = request.nextUrl.clone();
  url.pathname = "/login";
  return NextResponse.redirect(url);
}
return response;

The full files are in the practice folder; the cookie handling in lib/supabase/proxy.js follows Supabase's documented pattern.

(Your machine)

BASH
chmod +x make-env.sh
./make-env.sh
cd site
npm install
npm run build
npm start
  • chmod +x makes the script runnable; ./make-env.sh writes site/.env.local with the two public values, read from supabase status.
  • npm run build then npm start builds and serves the app on port 3007. Leave it running and use another terminal.

The build's route table:

TEXT
Route (app)
┌ ○ /_not-found
├ ƒ /login
└ ƒ /orders


ƒ Proxy (Middleware)

Both pages are ƒ dynamic, because they read cookies, and every visitor's page is different. ƒ Proxy confirms the proxy is in place.

A signed-out request for the orders page, from the lesson-07 folder:

(Your machine)

BASH
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" localhost:3007/orders

-o /dev/null throws away the page; -w prints just the status code and where it redirects.

TEXT
307 http://localhost:3007/login

Then login-test.cjs (needs Playwright, as in earlier lessons) uses a real browser: the orders page signed out, a wrong password, the right one, the cookie, and an edited cookie.

(Your machine)

BASH
node login-test.cjs
TEXT
1. Signed out, open /orders: landed on /login
2. Wrong password: "That email and password didn't match."
3. Right password: "Pre-orders for ada@juniper.test"
     Dark rye, pick up 2026-11-14
     Seeded sourdough, pick up 2026-11-14
     Baguette, pick up 2026-11-14
4. Cookie sb-127-auth-token: httpOnly=false, sameSite=Lax, secure=false
   Page JavaScript can read it: true
5. Edited cookie, open /orders: landed on /login

Three things to notice in line 4:

  • httpOnly=false: the login cookie can be read by JavaScript on the page. Supabase does that on purpose, so browser code can use the login too. The cost: if anyone gets a script onto your page (Lesson 5's MDX with JavaScript switched on, for example), it can read the token and act as that person until it expires. Keeping scripts you didn't write off your pages matters more once there are logins.
  • sameSite=Lax: the browser won't send it with most requests started by other sites, which blocks a common trick of making a signed-in visitor's browser submit a form to your site.
  • secure=false: fine on localhost. On a real site with HTTPS (Lesson 8), it's sent only over encrypted connections.

And line 5: Ada changed her own cookie to say she was Ben, and the proxy sent her back to the login page. To see why, you need to know there are two ways to ask "who is this?" on the server.

getSession() vs getClaims()

cookie.mjs does what Next.js does, without Next.js: a server client reads cookies from a jar. Ada signs in, then edits her cookie to say she's Ben, changing the user's email and id in the stored session and in the token's claims. Each cookie is then checked both ways:

JS
const { data: s1 } = await sb.auth.getSession();
const { data: c, error } = await sb.auth.getClaims();
console.log(`  getSession() says: ${s1.session?.user.email ?? "nobody"}`);
console.log(`  getClaims()  says: ${error ? `rejected (${error.message})` : c?.claims.email ?? "nobody"}`);

(Your machine)

BASH
node cookie.mjs
TEXT
Cookie set: sb-127-auth-token (2797 characters)
Real cookie:
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
  getSession() says: ada@juniper.test
  getClaims()  says: ada@juniper.test
Edited cookie:
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
  getSession() says: ben@juniper.test
  getClaims()  says: rejected (Invalid JWT signature)

getSession() believed the edited cookie; getClaims() didn't. The library even printed a warning each time getSession()'s user was used. The difference:

  • getSession() returns whatever is stored in the cookie, unchecked. It's for when you need the raw token to pass on, never for deciding who someone is.
  • getClaims() checks the token's signature. This project signs with ES256, which uses a private key to sign and a public key to check, so getClaims() can check locally with Supabase's published public key, without a network call to Auth each time.
  • getUser() (the one the warning suggests) asks Supabase Auth directly. It's slower, and useful when you need the latest account details or must know a user was deleted or signed out a moment ago.

Here the database would still have saved the day: a page that trusted getSession() would greet "Ben", but its queries would carry Ada's real token, or the forged one, which the API rejects. Pages often do more than query, though, like showing an admin menu or calling another service. Check with getClaims(), and let RLS be the second lock, not the only one.

What each part holds, and what the tests showed. Select the diagram to enlarge it.

When you're done, stop the app with Ctrl+C, and stop the local Supabase from the lesson-07 folder with npx supabase stop. Its data is kept for next time.

In real systems

  • Enable RLS on every table in an exposed schema, in the same migration that creates it. Run the advisor in CI with --fail-on error, so a table without RLS fails the build instead of reaching production.
  • Write a policy per action, and test them as different people, the way this lesson's scripts did: a stranger, the owner, another customer. Policies fail silently on reads, so a test that expects zero rows is as important as one that expects some.
  • Updates need both using and with check. Without the second, a user could change a row's user_id to someone else's.
  • Put admin work behind the secret key on a server, not behind a policy like "users whose email ends in @juniper.test". Better still, give staff a role in the token's claims or a staff table, and write policies for it.
  • Use the new keys. Publishable and secret keys can be rotated without logging everyone out, and you can create a separate secret key per server, so one leak doesn't mean replacing them all.
  • Turn on email confirmation and sensible password rules in a real project. The local defaults skip them for convenience.

Spot the problem

Work out what's wrong before you read the explanation.

"It's fine, the page filters by user." A gym's members area shows each member's bookings. The page code runs select * from bookings where member_id = <the signed-in member>, the members area requires a login, and testing with three accounts shows each member only their own bookings. RLS was never turned on, because "the page already filters". The publishable key is in the site's JavaScript. What's wrong? The filter is in the page, not the database, so anyone who skips the page skips the filter. A curious member opens developer tools, copies the project URL and key, and asks the API for bookings with no filter: every member's name and class times, and permission to change or delete them, exactly as the stranger did here. The fix: enable RLS and add policies so the database returns only the member's own rows whatever the query says; keep the page's filter if you like, but as tidiness, not security. Then check: does a request with only the publishable key, and no login, return zero rows?

Keep this

ThingWhere it livesTrust it to
Publishable keyBrowser, in NEXT_PUBLIC_ variablesIdentify your project; nothing more
Secret keyServer only, never NEXT_PUBLIC_Admin jobs; bypasses RLS
Login cookieBrowserNothing, until its signature is checked
getClaims()ServerWho is signed in (checks the signature)
getSession()ServerPassing the raw token on; not identity
RLS policiesDatabaseWhich rows each person can read and write
AlwaysRLS on every exposed table; a policy per action; test as a stranger, the owner and another user

Where you are now

You can run Supabase locally, create a table with a migration, protect it with row level security and policies, and test them as different people. You can sign people in with a Next.js server action, protect a page with a proxy and getClaims(), and say what the browser can and can't be trusted with. The next lesson puts the bakery on the internet: deploying to Vercel, with environment variables, previews and a real domain.

Check yourself

  1. The publishable key is in your page's JavaScript. Who can use it?

  2. RLS was on with no policies. What did Ada get when she read her own orders?

  3. What stopped Ada inserting an order in Ben's name?

  4. Ada tried to change the pickup date on her own order. What happened, and why?

  5. An edited login cookie said the visitor was Ben. Which check caught it?

  6. Where may the secret key be used?

Key terms

  • Supabase: a hosted Postgres database with logins (Auth), an HTTP API and other services, which you can also run locally.
  • Authentication and authorization: finding out who someone is (logging in), and deciding what they may do (policies).
  • Publishable key and secret key: Supabase's public key for browsers, limited by RLS, and its private key for servers, which bypasses RLS. They replace the anon and service_role keys.
  • Token (JWT, JSON Web Token): a signed message saying who someone is and until when. Anyone can read it; only the signer can make a valid one.
  • Row level security (RLS): a Postgres feature that checks each row against policies before a query can see or change it.
  • Policy: a rule for one table and one action: which rows can be seen (using) and which new rows are allowed (with check).
  • Migration: a numbered SQL file that changes the database, applied in order everywhere.
  • Server action: a function marked "use server" that a form can call; it runs on the server.
  • Proxy: Next.js 16's file that runs before matching requests; called middleware in Next.js 15 and earlier.
  • Cookie flags: HttpOnly (hidden from page JavaScript), SameSite (not sent with most requests from other sites) and Secure (HTTPS only).

Go deeper