The answer. For years a project had two keys that were both JWTs: anon (low privilege, shipped to browsers) and service_role (bypasses RLS, server only). They are being replaced by keys that are not JWTs and say what they are in the prefix. A publishable key (sb_publishable_…) is safe in web pages, mobile apps and public repos; it maps to the anon role and every query still goes through RLS. A secret key (sb_secret_…) maps to service_role, bypasses RLS entirely, and refuses to work from a browser (the API matches the User-Agent and returns 401), which turns the worst mistake into a loud failure. Both systems run side by side until you disable the legacy keys in the dashboard; the legacy keys are scheduled for deprecation by the end of 2026, so new code should read the new ones.
The pattern.
// browser or SSR user client: publishable key, RLS applies, user JWT rides in Authorization
const supabase = createBrowserClient(env.PUBLIC_SUPABASE_URL, env.PUBLIC_SUPABASE_PUBLISHABLE_KEY);
// server-only admin client: secret key, no cookies, never used for sessions
const admin = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY, { auth: { persistSession: false } });
Headers: apikey: sb_publishable_… Authorization: Bearer <user access token>
Watch out.
- The new keys travel in the
apikeyheader only; putting a publishable key inAuthorization: Bearerno longer means anything. - Creating new keys does not revoke the old ones. Rotate by creating, deploying, then disabling the legacy pair.
- Grep every
VITE_,NEXT_PUBLIC_andEXPO_PUBLIC_variable forsb_secret_orservice_rolebefore each release; a secret key in a public bundle is a full database leak.
Related: what-supabase-is-postgres-behind-kong · hosted-user-tokens-es256-not-hs256 · server-side-trust-getclaims-not-getsession