AI-coded apps

    Supabase anon key vs service_role key: which goes where

    Supabase anon key vs service_role key technical comparison

    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 anon Postgres role. Unauthenticated requests use it; authenticated requests add a user JWT and present as authenticated.
    • The service_role key carries the service_role Postgres role. That role has BYPASSRLS, which means every Row Level Security policy is skipped.
    • The new format adds browser detection. A request sending a secret key in the apikey header 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:

    KeyWhere it goesWhere it must never goWhat it grants
    anon / sb_publishable_*Client bundles, mobile apps, desktop apps, public GitHub reposNowhere is off-limitsWhatever RLS allows for the anon or authenticated role
    service_role / sb_secret_*Edge Functions, scheduled jobs, trusted backend servicesBrowsers, mobile apps, desktop apps, public source code, AI chat historyFull schema access; bypasses every RLS policy
    JWT secretSupabase dashboard onlyAnywhere outside the dashboardSigns 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:

    1. The legacy anon key starts with eyJ (JWT prefix) and decodes to a token with "role": "anon" in the payload at jwt.io. The legacy service_role key has the same prefix and decodes to "role": "service_role".
    2. The new format keys start with sb_publishable_ or sb_secret_. The prefix is part of the key itself, not just a label.
    3. 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_ and sb_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.
    • #supabase
    • #api-keys
    • #service-role-key
    • #anon-key
    • #ai-coded apps
    • #security

    Frequently asked questions

    Should I migrate from the legacy anon and service_role keys to the new sb_publishable and sb_secret format?
    Supabase recommends it. The new format rotates independently of the JWT secret, which solves the legacy headache where rotating a leaked service_role key invalidated every issued user JWT. New secret keys also include a browser-detection guard that rejects requests sending the secret in an apikey header, which catches the most common exposure mistake automatically.
    Can I have both the legacy and new keys active in the same project?
    Yes during the migration window. Supabase allows the legacy keys and the new sb_publishable / sb_secret keys to coexist while you update consumers. Once every Edge Function and client uses the new format, disable the legacy keys in the dashboard to close the older surface.
    Why does the new secret key sometimes reject requests from the browser?
    Supabase added a server-side check that inspects the apikey header. If the value matches a secret-key prefix and the request looks like it came from a browser (User-Agent, Origin), the request is rejected with a 401. This is a defensive guard against the most common exposure mistake; it does not replace the developer's responsibility to keep secret keys out of the bundle.
    Are there hidden differences between the anon and authenticated roles?
    Both come from the same publishable key, but the role inside the request changes when a user signs in via Supabase Auth. The publishable key alone presents as the anon role; the publishable key plus a valid user JWT in the Authorization header presents as authenticated, with auth.uid() resolving to the user. RLS policies usually need both roles addressed.
    What happens if I rotate the JWT secret on a legacy project?
    Every previously-issued user JWT becomes invalid. Existing logged-in users are forced to sign in again, and any third-party service holding a Supabase-issued token loses access until it refreshes. This is the cost of the legacy rotation model and the reason Supabase added the new key format with independent rotation.

    Keep reading

    Scan your app in minutes

    Upload an APK, AAB, or IPA. PTKD returns an OWASP-aligned report with copy-paste fixes.

    Try PTKD free