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(), notgetSession(), 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-js2.119.0,@supabase/ssr0.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" fooledgetSession();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)
cd lesson-07
npm install
SUPABASE_EXPERIMENTAL_STACK=1 npx supabase init
npx supabase start -x realtime,storage,functions,studio,mail,analytics,pooler
npm installinstalls the CLI and the two client libraries into the folder, sonpx supabaseruns version 3.0.0.supabase initcreatessupabase/config.toml, the local project's settings.SUPABASE_EXPERIMENTAL_STACK=1turns 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 startdownloads and starts the local services.-xleaves 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:
| Service | State |
|---|---|
database | running · healthy · eager |
rest | sleeping · starts on first request |
auth | sleeping · starts on first request |
Then two last lines:
Runtime: native
Run supabase status --env to export these values as environment variables.
databaseis Postgres, started straight away (eager).restis the API in front of the database, andauthis 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 oldanonkey. - Secret key (starts
sb_secret_): goes only on servers you control. It bypasses RLS completely, like the oldservice_rolekey.
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:
-- 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 identitynumbers 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.publicis 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)
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:
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)
node seed.mjs
signed up ada@juniper.test
reserved 2
signed up ben@juniper.test
reserved 1
The heart of it, for each person:
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.
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)
node peek.mjs
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)
npx supabase db advisors --type security --fail-on error 2>/dev/null | grep -o '"detail": *"[^"]*"'; echo "exit code: ${PIPESTATUS[0]}"
db advisors --type securityruns Supabase's security checks against the local database and prints the results as JSON.--fail-on errormakes 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 theTarget:line and keeps only each problem's description.${PIPESTATUS[0]}is the advisor's exit code, notgrep's.
"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):
alter table public.preorders enable row level security;
(Your machine)
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:
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());
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)
npx supabase db advisors --type security --level info 2>/dev/null | grep -o '"detail": *"[^"]*"'
"detail": "Table `public.preorders` has RLS enabled, but no policies exist"
Policies: who owns each row
steps/03-policies.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.preordersadds a named rule to the table.for selectandfor insert: a policy covers one kind of action (orall). There's no policy forupdateordelete, 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 itsuser_idis 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 withselect, 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)
cp steps/03-policies.sql supabase/migrations/20261107000003_policies.sql
npx supabase migration up
node mine.mjs
node peek.mjs
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:
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:
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)
node attack.mjs
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
with checkrefused the forged owner. Without it, thedefault auth.uid()would only be a default; the browser could send anyuser_idit liked.- Ben's order was untouched, silently.
- 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) andwith check(what they may become), or Ada could move her order onto Ben's account. - 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:
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)
node server.mjs
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:
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/ssris 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.getAllandsetAllare 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:
"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:
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:
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)
chmod +x make-env.sh
./make-env.sh
cd site
npm install
npm run build
npm start
chmod +xmakes the script runnable;./make-env.shwritessite/.env.localwith the two public values, read fromsupabase status.npm run buildthennpm startbuilds and serves the app on port 3007. Leave it running and use another terminal.
The build's route table:
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)
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.
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)
node login-test.cjs
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 onlocalhost. 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:
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)
node cookie.mjs
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, sogetClaims()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.
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
usingandwith check. Without the second, a user could change a row'suser_idto 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
| Thing | Where it lives | Trust it to |
|---|---|---|
| Publishable key | Browser, in NEXT_PUBLIC_ variables | Identify your project; nothing more |
| Secret key | Server only, never NEXT_PUBLIC_ | Admin jobs; bypasses RLS |
| Login cookie | Browser | Nothing, until its signature is checked |
getClaims() | Server | Who is signed in (checks the signature) |
getSession() | Server | Passing the raw token on; not identity |
| RLS policies | Database | Which rows each person can read and write |
| Always | RLS 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
The publishable key is in your page's JavaScript. Who can use it?
RLS was on with no policies. What did Ada get when she read her own orders?
What stopped Ada inserting an order in Ben's name?
Ada tried to change the pickup date on her own order. What happened, and why?
An edited login cookie said the visitor was Ben. Which check caught it?
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
anonandservice_rolekeys. - 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) andSecure(HTTPS only).
Go deeper
- Supabase: row level security (opens in a new tab), API keys (opens in a new tab) and migrating to publishable and secret keys (opens in a new tab).
- Supabase: server-side auth for Next.js (opens in a new tab), including
getClaims(),getUser()andgetSession(). - Supabase: the database advisor's checks (opens in a new tab).
- Try: add an update policy so customers can change a pickup date, with both
usingandwith check. Test that Ada can move her dark rye to another day, and can't move it onto Ben's account by changinguser_id. - Try: change
app/orders/page.jsto usegetSession()instead ofgetClaims(), and remove the check from the proxy. What does an edited cookie show on the page now, and what do the queries return?