Supabase ships two API keys with every project. One is meant for client code; the other is meant for trusted server environments. Mixing them up is the single most common Supabase security failure in AI-coded apps, and the failure mode is dramatic enough that it has its own CVE. This page covers the technical differences in detail so the choice is no longer guesswork.
Short answer
The anon key (now sb_publishable_* in the new format) is the publishable client key that only grants whatever your Row Level Security policies allow. The service_role key (now sb_secret_*) bypasses every RLS policy via the Postgres BYPASSRLS attribute and must never appear in a browser, mobile, or desktop bundle. According to Supabase's official API keys documentation, the publishable key is "safe to expose online: web page, mobile or desktop app, GitHub actions, CLIs, source code"; the secret key is for trusted server environments only.
What you should know
- The anon key carries the
anonPostgres role. Unauthenticated requests use it; authenticated requests add a user JWT and present asauthenticated. - The service_role key carries the
service_rolePostgres role. That role hasBYPASSRLS, which means every Row Level Security policy is skipped. - The new format adds browser detection. A request sending a secret key in the
apikeyheader from what looks like a browser is rejected at the gateway. - The legacy and new formats coexist during migration. Both work until you disable the legacy pair in the dashboard.
- The most common AI failure mode is substituting the keys. The AI agent picks the service_role key for a client-side admin write because the policy was too restrictive for the anon key.
What is the anon (publishable) key for?
The anon key is a JWT (legacy format) or a prefixed token (new sb_publishable_* format) that represents an unauthenticated visitor to your project. Every Supabase project ships one, and it is meant to be shipped to the browser. When the client sends the key with a request, PostgREST runs the resulting SQL under the anon role.
The anon role has no direct table privileges. Whatever access the visitor gets comes from your Row Level Security policies and, in some cases, from explicit GRANTs on specific tables. With RLS enabled and policies written correctly, the anon key is effectively powerless without authentication: the visitor can hit the API, but every query filters down to nothing.
When the visitor signs in through Supabase Auth, the client gets a user JWT. That JWT is sent in the Authorization header alongside the anon key in the apikey header. The role on the request is now authenticated, and auth.uid() resolves to the user's UUID. The same RLS policies evaluate, but they can now distinguish the signed-in user from a stranger.
What is the service_role (secret) key for?
The service_role key is the administrative counterpart. According to the Supabase database documentation on Row Level Security, the service_role Postgres role carries the BYPASSRLS attribute, which means every RLS policy on every table is skipped when the role queries the database. The role can read, write, and delete anything in the schema without authentication and without per-row filtering.
The legitimate uses are server-side: scheduled jobs that need to read every user's data, Edge Functions that perform privileged writes after validating a user JWT manually, admin scripts run from a trusted environment. None of those uses involve sending the key to a browser.
The Supabase docs are direct about the constraint: "Never use in a browser, even on localhost," and: "Do not bundle in executables or packages for mobile, desktop or CLI apps." The CVE-2025-48757 disclosure covered in the Superblocks technical writeup documents 170+ Lovable apps that violated this constraint and the data exposed as a result.
What is the new sb_publishable_* / sb_secret_* format?
Supabase introduced a new key format in 2025 that replaces the JWT-based anon and service_role keys with non-JWT tokens. The format change is more than cosmetic.
First, the new keys rotate independently of the JWT secret. In the legacy model, rotating a leaked service_role key required rotating the JWT secret, which invalidated every previously-issued user JWT and forced every signed-in user to log in again. The new format lets you rotate a compromised secret key in seconds without disrupting active user sessions.
Second, the new secret keys include a browser-detection guard. The Supabase API gateway inspects requests for the combination of a secret-key-shaped apikey value and browser-like headers (User-Agent, Origin). When the combination is present, the request is rejected with a 401, on the assumption that a secret key in a browser request is almost certainly a mistake. This catches the most common AI-coding failure pattern automatically.
Third, the new format supports per-component secret keys, so a developer can issue separate secrets for separate backend services. A leak from one Edge Function does not require rotating the keys used by every other service. Supabase recommends migrating to the new format and disabling the legacy keys once every consumer is updated.
Where does each key belong?
The quick reference for where each key is supposed to live:
| Key | Where it goes | Where it must never go | What it grants |
|---|---|---|---|
anon / sb_publishable_* | Client bundles, mobile apps, desktop apps, public GitHub repos | Nowhere is off-limits | Whatever RLS allows for the anon or authenticated role |
service_role / sb_secret_* | Edge Functions, scheduled jobs, trusted backend services | Browsers, mobile apps, desktop apps, public source code, AI chat history | Full schema access; bypasses every RLS policy |
| JWT secret | Supabase dashboard only | Anywhere outside the dashboard | Signs and verifies user JWTs |
The apikey header carries the key on every request. PostgREST authenticates the key against the Supabase API gateway, looks up the role, and runs the SQL under that role. There is no way to switch from anon to service_role mid-request, which is why the substitution at the client level is the entire attack surface.
How do you tell which key you have?
Three signals:
- The legacy
anonkey starts witheyJ(JWT prefix) and decodes to a token with"role": "anon"in the payload at jwt.io. The legacyservice_rolekey has the same prefix and decodes to"role": "service_role". - The new format keys start with
sb_publishable_orsb_secret_. The prefix is part of the key itself, not just a label. - In the Supabase dashboard under Project Settings, API Keys, each key is labeled with its role and its format. Treat the labels as the source of truth; if a key in your code does not match the dashboard, the code is wrong.
For mobile apps that compile a Supabase backend into the binary, PTKD.com (https://ptkd.com) parses the strings out of an APK or IPA and flags any token whose decoded role is service_role or whose prefix is sb_secret_. The scanner ties the finding to the OWASP MASVS storage and credential controls and suggests the rewrite back to the publishable key plus an Edge Function for the privileged path.
What to watch out for
Three details that recur in audits.
First, the anon key alone is not enough. The anon key combined with a missing or USING (true) RLS policy lets any visitor read every row, even though the key itself is technically the right one. Both pieces have to be right.
Second, custom JWT signing with the legacy service_role JWT secret is a footgun. Some AI builders generate Edge Functions that sign user-like JWTs with the JWT secret directly, which creates tokens Supabase Auth cannot revoke. Use auth.admin.createUser() and auth.admin.generateLink() instead, which produce normal Supabase-managed tokens.
Third, third-party services that integrate with Supabase sometimes ask for the service_role key during setup. Most of those services have a workflow that needs the key for one-time data import or schema introspection. Provide the key for that flow, then either rotate it (legacy format) or revoke it (new format) when the integration completes. Leaving a service_role key in a third-party tool's config is a slow-motion version of leaving it in the client bundle.
Key takeaways
- The anon key is for clients; the service_role key is for servers. The distinction is not a recommendation, it is enforced by Postgres
BYPASSRLS. - The new
sb_publishable_andsb_secret_format adds browser detection and independent rotation. Migrate when convenient. - Lovable, Cursor, and similar AI builders confuse the two keys often enough to have produced a CVE. Treat any client-side admin write as a red flag during code review.
- For mobile apps that bundle a Supabase backend, PTKD.com (https://ptkd.com) scans the compiled binary for secret-shaped tokens and maps each finding to the relevant OWASP MASVS control.
- Document which key each part of your stack uses in your CHANGELOG, so the next AI-generated change does not silently flip the wrong one into a client component.




