Most Supabase projects start with one client, created with the anon key, used everywhere: components, route handlers, server actions, scripts. It works, so nobody revisits it. Then a server action needs to read a row the current user cannot see, and the fastest fix (swap in the service role key) quietly removes every policy you wrote.
Both halves of that story are mistakes. They are different mistakes with different fixes.
What the anon key actually is
The anon key is a JWT with the claim role: anon, signed with your project's
secret. It is shipped in your JavaScript bundle. It is not secret and was never
meant to be. Its only job is to say "this request belongs to project X, from an
unauthenticated visitor".
Everything the anon key can do is exactly what your row level security
policies grant to the anon role. If a table has RLS enabled and no policy
for anon, the anon key can do nothing to it. If a table has RLS disabled, the
anon key can read and write all of it: from anyone's browser, anywhere.
So: rotating the anon key is not a security control, and "the anon key leaked" is not an incident. "A table has RLS off" is the incident.
Why server code with a bare anon client is wrong
On the server, a client built from the anon key alone is anonymous. Every
query runs as the anon role, regardless of who is logged in. Two things follow:
- Queries that should return the signed-in user's rows return nothing, because
auth.uid()is null. The usual "fix" for this is the service role key, and that is how projects end up with no RLS in practice. - Any data you do get back is by definition public data, so you have paid a network round trip to fetch something you could have cached.
The server is where you know who the caller is. Use that.
The three server-side doors, and when each is right
1. Act as the user: supabaseAsUser(accessToken)
Take the caller's access token from their session and attach it. Now
auth.uid() is populated inside Postgres, and every policy you wrote applies:
the database enforces authorisation, not your if statements.
Where that token comes from depends on who issues identities. With Supabase Auth
it is the access token on the cookie session: getSession() in
@/lib/auth/server. With a provider that signs its own tokens (Clerk, Better
Auth) PostgREST rejects them until you register the provider under
Authentication -> Third-party Auth in the dashboard; until you have done
that, door 2 below is the only honest option.
import { supabaseAsUser } from "@/db";
export async function myDocuments(accessToken: string) {
const supabase = supabaseAsUser(accessToken);
// No .eq("owner_id", userId) needed: the policy does it, and cannot be forgotten.
const { data, error } = await supabase.from("documents").select("id, title");
if (error) throw error;
return data;
}
This is the default for anything acting on behalf of a signed-in person. The security property is worth restating: a missing filter here is not a data leak, because the policy is applied in the database on every row.
2. Query as the ORM: sql from @/db
The Postgres role your DATABASE_URL uses bypasses RLS entirely. That is
correct for joins, aggregates, transactions and anything reporting-shaped, but
it means you are the authorisation layer for those queries.
// Every request-derived query filters by the authenticated user id, explicitly.
const rows = await sql`
select id, title from documents
where owner_id = ${session.userId}
order by created_at desc limit 50
`;
The rule that keeps this honest: the user id in that filter must come from the verified session, never from a form field, a search param, or a header.
3. Act as the system: supabaseAdmin()
The service role key is a JWT with role: service_role, and Postgres treats it
as bypassing RLS. It is for things with no user and no SQL equivalent: creating a
signed Storage URL, deleting an auth user, broadcasting on Realtime, a scheduled
job reconciling data.
Rules for every supabaseAdmin() call site:
- It lives in server-only code.
src/db/client.tsimportsserver-only, so an accidental client import is a build error rather than a leaked key. - The key never gets a
NEXT_PUBLIC_prefix, never becomes a component prop, never appears in a log line or an error returned to the browser. - The call is narrow and specific.
supabaseAdmin().from("users").select("*")in a page is not "using the admin client", it is turning RLS off for that page. - There is a comment saying why the user-scoped path could not do it.
If you cannot write that comment, you want door 1 or door 2.
Migrating an existing codebase
Grep for every client construction. There should be exactly three factories in the whole repo (browser, as-user, admin) and everything else imports them.
For each server call site, ask: does this act for a signed-in person? Then door 1 or 2. Is it a system action? Then door 3, with a comment.
Confirm RLS is on everywhere:
select relname from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind = 'r' and not c.relrowsecurity;Any row in that result is a table the anon key in your bundle can read.
Check for policy-free tables too: RLS on with no policy is closed to everyone but the service role, which is a valid deliberate choice and a confusing accident.
The browser is not exempt
createBrowserSupabase() uses the anon key plus whatever session the browser
holds, with Supabase Auth that is the cookie session, and with no Supabase
session it is a plain anonymous client. Either way it is correct for what it is
for: realtime subscriptions and direct Storage uploads.
What is not correct is deciding "the browser only ever sends safe queries". The
browser sends whatever the person operating it sends. Policies are the only
thing standing between the anon key and your tables.
The short version
The anon key identifies the project, not the caller. On the server, attach the caller's token so policies can do their job; use the ORM connection when you need SQL and take responsibility for the filter; use the service role only for system work, in server-only modules, one narrow call at a time, with a reason written down.