The server/client key split done wrong in Next.js + Supabase
Next.js applications talk to Supabase from two runtimes with different trust levels: the browser, where everything is public, and the server, where secrets may live. The two-key model maps onto this neatly — anon key in the browser, service_role on the server — and Next.js provides the boundary mechanism: variables prefixed with NEXT_PUBLIC_ are inlined into client bundles, everything else stays server-side. The split is simple, documented, and wrong in a number of specific ways that keep recurring.
This page catalogues the ways the split breaks in practice, because they share an uncomfortable property: the application works perfectly afterward. A service_role key in a client bundle degrades nothing — it bypasses every policy, so all queries succeed. Detection requires looking at the artifact the way an attacker does, which is what the SERVICE_ROLE_KEY_EXPOSED probe automates; doing it once by hand is the starting point below.
Rules that check this
- criticalservice_role key shipped to the browser
SERVICE_ROLE_KEY_EXPOSED
Two keys, two runtimes
The model bears restating precisely. The anon key identifies an unauthenticated or per-user caller and relies on Row Level Security for safety; it belongs in every bundle. The service_role key carries BYPASSRLS and answers to nothing but network reachability; it belongs exclusively in server processes — route handlers, server actions, server components that never cross to the client. Next.js enforces the split at variable-naming level: NEXT_PUBLIC_ prefixed values are replaced into client JavaScript at build time.
Everything that follows is a variation on one theme: values crossing the boundary they were named to prevent. The prefix system is reliable; the humans and models wiring values into it are busy. And the failure is silent by construction — a bypass credential in the browser does not break the app, it completes it, removing friction everywhere at once.
Three ways the split breaks
The prefix accident: an environment file where SUPABASE_SERVICE_ROLE_KEY gains NEXT_PUBLIC_ through copy-paste or a well-meant fix for a "missing" variable — the build obligingly inlines an RLS-bypassing credential into every chunk. The import accident: a shared module exporting a pre-configured admin client, imported from a component that later gains the use-client directive, dragging the server-only dependency into the client graph. The SSR accident: assuming server-rendered means private, when values reaching client components as props or serialised state ship regardless of which component fetched them.
Generated and AI-assisted code adds a fourth pattern: the model needing an elevated operation finds the service key available and wires it where the call was being written — sometimes client-side, because that was where the component lived. None of these produce warnings. All of them produce a working deployment containing a credential whose possession ends every policy conversation in the project.
Proving your split holds
The proof is in the artifact, not the repository. After a production build, search what browsers download — static chunks and assets — for credential shapes. Long JWT-shaped strings warrant decoding: the role claim distinguishes anon from service_role without verifying signatures, because the question is what the key claims to be:
Minification protects nothing here: bundlers inline string values verbatim, and JWT-shaped strings survive renaming untouched. False positives happen — fixtures, example tokens in comments — which is why decoding matters. The role claim inside the payload settles identity in seconds, and service_role in that position leaves no room for interpretation.
# after next build — inspect what clients actually receive
grep -rEo "eyJhbGciOi[A-Za-z0-9._-]{30,}" .next/static | sort -u
grep -rn "service_role\|sb_secret_" .next/static \
|| echo "no service-role markers in client chunks"The structure that holds
Structural defences beat vigilance. Create the admin client in exactly one module guarded by a runtime check that throws when imported into a client context, and import that module only from route handlers and server actions. Keep service-key operations behind endpoints with their own authorisation, so the browser never needs bypass capability. Name variables so the dangerous one is visually loud, and review any diff adding NEXT_PUBLIC_ to anything Supabase-shaped with disproportionate suspicion.
For the scheduled version of the hand check above: RowShield's probe fetches the deployed site, follows same-origin scripts and module preloads, decodes JWT role claims and recognises newer key formats, raising a critical SERVICE_ROLE_KEY_EXPOSED finding on a hit — stored as a fingerprint, never the value. It is an independent product, unaffiliated with and not endorsed by Supabase; the probe issues read-only methods, refuses private network ranges before connecting, and re-checks on every redirect hop.
Frequently asked
- Is NEXT_PUBLIC_SUPABASE_ANON_KEY in the client bundle acceptable?
- Yes — the anon key is designed for distribution and its power is bounded by Row Level Security on each exposed table. The variable that must never gain the prefix is the service_role key; one is an identity for unauthenticated callers, the other a credential that bypasses every policy in the project.
- I found a service_role key in my bundle. What now?
- Rotate first, in the Supabase dashboard, before anything else — copies are unverifiable and rotation is instant. Every dependent call breaks, handing you the inventory of what used it. Move each caller behind a route handler or server action, rebuild, and re-run the bundle search to confirm the new key stays server-side.
- Do server components hide keys from the browser?
- Server components render on the server, but their output crosses to the client, and any secret passed through props, context or serialised state ships with it. The reliable rule concerns where code runs at request time — route handlers and server actions — not which component tree performed the fetch.
Check your project in about ten seconds
Paste a URL. No signup, no writes, nothing stored.
Run the free audit