Blog

How to check if your Supabase service role key is exposed (and what to do)

The Supabase service role key bypasses row-level security. How it lands in client code, how to check your app, and how to rotate it.

Free check

Check the live app

Paste the public URL. The check reads the page a visitor already gets, then gives you a private report. It does not log in or change the app.

Free, one time, and limited to what a stranger can already see. No account required.

A service role key or secret key in your browser bundle, public GitHub repo, or Lovable, Bolt, v0, or Cursor project is a leak. Supabase's API keys guide says a secret key bypasses every row-level security policy and must never go in a browser, a shipped app, or source control. Securing your data says the secret and service role keys are never safe to expose. They belong on the backend.

This page is about that key. The anon key and the publishable key (sb_publishable_) are meant to be in the page. To read an anon JWT, see how to check if your Supabase anon key is exposed. Stay here if the role is service_role or the value starts with sb_secret_.

What the service role key can do

The API keys guide maps each key to a Postgres role. A publishable key runs as anon with nobody signed in, and as authenticated with a signed-in user. A secret key runs as service_role, which has the BYPASSRLS attribute, so policies never apply to it. Postgres still checks table grants first, and a missing grant returns a permission error, even for service_role. A policy that matches no rows returns an empty result. That's what the publishable key sees when every row is excluded. A secret key skips that policy.

Supabase's note on a service role client says a client whose Authorization header is the service role key always bypasses row-level security. createClient puts that key on the Authorization header by default. Row-level security follows that header. The apikey header is separate. Adding service_role to a policy does nothing, since that role never runs policies. Server client still getting a row-level security error? A user session replaced the key (server-rendering clients copy it from cookies, and signUp can return one) or the client was created with the publishable key. Use a separate supabase-js client for the secret key, and admin.createUser() when that client creates a user.

Legacy JWTs and the sb_secret_ key

The API keys guide lists four key types. sb_publishable_ is the publishable key. sb_secret_ is the secret key. The anon key is the legacy publishable key, and the service role key is the legacy secret key. Both are long-lived JWTs. The new keys are short strings. If a tool, tutorial, or AI assistant tells you to copy a long key starting with eyJ, it was written for the legacy keys.

Supabase is deprecating the anon and service_role keys by the end of 2026. Creating a new key leaves the legacy keys working until you disable them under Settings, then API Keys.

The new secret key adds a browser check: Supabase matches the User-Agent header and returns HTTP 401 Unauthorized when a secret key is used in a browser. Other tools can still send that key, so the 401 isn't a reason to ship it. The legacy value is the service_role JWT, and anyone who can read it in your JavaScript can copy it.

Pair the publishable or anon key with row-level security. Policies: Supabase row-level security. The wider key list: API keys in the frontend.

How the key gets into client code

Lovable, Bolt, v0, and Cursor generate the file the browser downloads. Hand checks: Lovable, Bolt, v0, and Cursor. Open the generated createClient call. A publishable key or anon JWT there matches the docs. A service_role JWT or sb_secret_ value there is the leak. A NEXT_PUBLIC_ or VITE_ prefix publishes that value.

Next.js inlines a NEXT_PUBLIC_ variable into the browser bundle at next build. Other names stay on the server. Supabase's example puts the publishable key in NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY and the secret in SUPABASE_SECRET_KEY, and says never to prefix the secret. The default template gitignores .env files.

Vite exposes VITE_ variables in client source at build time and says they shouldn't hold API keys. VITE_SUPABASE_SERVICE_ROLE_KEY publishes this key. Vite says to gitignore *.local.

A public GitHub repo counts as source control. A secret key must never appear in a web page, source code, or source control, CI scripts included. Deleting the line later still leaves the old copy in git history. The publishable key is the one that guide allows in source. It also says a secret key must never travel over chat, email, or SMS, and you shouldn't log one. URLs, query parameters, and request headers often get logged. Write the value into your host's secret store instead of printing it, or it ends up in CI logs.

How to check your own app

Open your published URL. View the page source, search the JavaScript from your domain, and search the repo history if the project is on GitHub.

  • A value that starts with sb_secret_.
  • An eyJ JWT. Decode the middle segment on your own machine and read role. service_role is this leak. anon is the public key in the anon key note. authenticated should be created at sign-in.
  • A NEXT_PUBLIC_ or VITE_ name holding either secret.
  • A database URL with a password. That password is a different secret.

Sending the live key to a website or chat makes another copy. Then search the builder transcript and host logs for the same string.

What to do right now

Fix the leak before you rotate. Vercel's rotation guide and the API keys guide use the same order: remove the key, create a new secret key, update every consumer, redeploy, and delete the exposed key last. Delete first and you break deployments that still hold the old value.

  1. Remove the key from client code, NEXT_PUBLIC_ and VITE_ variables, the repo, and the builder chat. Don't commit the new value.
  2. Create a new secret key under Settings, then API Keys. If the leak is a service_role JWT, replace it with that new secret key. If you got Supabase from the Vercel Marketplace, open the project from Integrations, Manage, the database, then Open in Supabase.
  3. Copy the new value into every backend, then redeploy. On Vercel, update the environment variables for production, preview, and development if they use the key. A deployment keeps the values from its build, and an older deployment fails once that credential is invalidated. On Netlify or any other host, update the same server-only variable and redeploy first. Use SUPABASE_SECRET_KEY. Edge Functions receive SUPABASE_SECRET_KEYS. SUPABASE_SERVICE_ROLE_KEY is still the legacy key.
  4. Confirm the server path works. The browser should use the publishable key, or the anon key if you haven't migrated it.
  5. Retire the exposed key last. Only delete a secret key in API Keys after every component has moved. Deletion is permanent. On that screen, Vercel says to open the three dots, choose Delete the API key, and type the name. Deactivate a legacy service_role JWT there instead. Deactivation is reversible, and creating the new key doesn't deactivate the JWT on its own.
  6. Search server logs, CI logs, and the builder transcript for the old value and remove it. To note which key it was, keep at most six characters after the prefix, or store a SHA256 hash.

Rotating API keys doesn't sign users out. Changing the legacy JWT secret does, since that secret signs access tokens, including the anon and service_role JWTs. The database password is separate. Vercel documents that reset under Database, then Settings. It drops open connections, so only do it if the password leaked. A browser 401 means the User-Agent check blocked the new secret key there. Other tools can still send it.

The pattern that belongs in the app

The browser gets the publishable key (NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY in Supabase's example). Turn on row-level security and write policies for anon and authenticated before you deploy. Securing your data says that key is safe to expose once the policies and grants are in place.

Keep the secret key in a server route, worker, or Edge Function, read as SUPABASE_URL and SUPABASE_SECRET_KEY, on its own supabase-js client. In an Edge Function, withSupabase({ auth: 'secret' }) returns ctx.supabaseAdmin, which bypasses row-level security, and the key stays out of the function source. Set verify_jwt = false only on the function called with a secret key. If you build the client yourself, read SUPABASE_SECRET_KEYS. SUPABASE_SERVICE_ROLE_KEY still holds the legacy key. Send the new keys on the apikey header. They aren't JWTs, and passing the migration-era verify_jwt check doesn't authenticate the caller. One secret key per backend limits a leak to one rotation. Hand pass: the checklist.

What a free Deploy Safely check can see

The free check fetches your public page and the JavaScript it loads, inline scripts included. It doesn't log in, open the Supabase dashboard, clone a GitHub repo, or use any secret it finds.

It looks for a JWT starting with eyJ, decodes the payload, and doesn't save the token. Role service_role is critical. The title is "A Supabase service role key is in the public page" when the issuer contains supabase or the payload has a ref claim, and "A service_role key is in the public page" otherwise. The check did not use the key. The word service_role alone isn't a finding. A Supabase JWT with role anon is an info note, "Your Supabase public key is in the page. That part is normal," covered in the anon key note.

The check does not have a separate rule for sb_secret_ or sb_publishable_. Search the bundle for sb_secret_ yourself.

It only checks tables when a supabase.co URL and a legacy anon JWT are already on the page. It sends that anon key, requests one row per table up to a short cap, and doesn't store the row or send a service role key. An empty answer can mean a lock or an empty table.

The same pass requests /.env, /.env.local, /.env.production, /.git/config, /.git/HEAD, and /.npmrc. It reports an env file on a non-HTML 200 with NAME=value lines, listing names without the values. It notes missing browser safety headers when two or more of the watched headers are absent. Secret patterns aren't applied to a source map. A clean report means this scan didn't see a service_role JWT. A server-only key is outside the check. Run it again after you redeploy.

A prompt you can paste

Paste this into the builder, publish again, and run the check a second time. If a secret was already on the live site, rotate it in that vendor's dashboard. The prompt cannot do that part.

Move the Supabase service role key and the secret key out of client code. Keep the publishable key in the browser.

1. Search client components and any NEXT_PUBLIC_ or VITE_ variable for a JWT whose role is service_role, a key that starts with sb_secret_, and a database URL with a password. Remove them from client files. The anon key and the sb_publishable_ key may stay.
2. Create the browser client with the publishable key, or the legacy anon key. Enable row-level security on every exposed table and write policies for anon and authenticated. Do not add service_role to a policy.
3. Keep a separate server client. Read SUPABASE_URL and SUPABASE_SECRET_KEY with no NEXT_PUBLIC_ or VITE_ prefix. Do not reuse a cookie-based server client, because the user session replaces the key on the Authorization header. In an Edge Function, use the server SDK with auth set to secret, or read SUPABASE_SECRET_KEYS. Set verify_jwt to false only on that function.
4. List every file, env name, and chat where the secret appeared, without printing it. I will create a new secret key, update the server environment on Vercel, Netlify, or the host that runs the backend, redeploy, and only then delete the secret key or deactivate the legacy service_role key.

Do not put the secret key back in the client to get a query past row-level security.

What the free check looks at

  • The public page and the JavaScript it loads, for common leaked secret keys: a Supabase service role key, OpenAI, Anthropic, Stripe secret keys, AWS, GitHub, Slack, private keys, and database URLs that include a password. A Supabase anon key is called out as expected in the browser.
  • A short list of public paths: /.env, /.env.local, /.env.production, /.git/config, /.git/HEAD, and /.npmrc.
  • A source map, only when a script on the page already points at one, and only when that map includes original source.
  • Supabase tables, only when the page already includes a supabase.co address and the public anon key. It asks for one row per table, then drops that row.
  • Firebase Realtime Database and Firestore, only when the public config is already in the page. It uses that public config and does not sign in.
  • A few browser safety headers on the main response. Missing headers are noted when two or more of the watched ones are absent. They are less urgent than a leaked key.

What stays outside it

  • It reads. It does not log in, submit forms, or change data.
  • It does not guess passwords, fuzz inputs, or hunt through hidden paths.
  • It does not use any secret it finds, including a service role key.
  • It does not open your builder's dashboard. A rule that lives only there can stay invisible.
  • A clear result describes this public look. It is not a certificate that the whole app is safe.

Free check

Run it on the published URL

Use the address a customer would open. The report stays private, and the shareable scorecard hides the details.

Free, one time, and limited to what a stranger can already see. No account required.

Security news for vibe coders. Two lines a day.

One story for Lovable, Bolt, Cursor and Supabase apps. Plus a fix when there is one.

From me, Allie. Weekdays. Unsubscribe anytime.

Keep going

All builder guides · All notes