# Server code should stop using the anon key, and must not reach for the service role instead

> The anon key is a public identifier, not a credential. Server work needs either the caller's JWT or a deliberate, audited service-role call. Here is how to tell which.

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:

1. 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.
2. 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.

```ts
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.

```ts
// 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.ts` imports `server-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

1. Grep for every client construction. There should be exactly three factories
   in the whole repo (browser, as-user, admin) and everything else imports
   them.
2. 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.
3. Confirm RLS is on everywhere:

   ```sql
   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.
4. 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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
