Skip to main content
Stackbastion

Supabase service_role key exposed: how to check and fix it

8 August 2026· 6 min read · by Stackbastion

If you built your app on Supabase, “check for exposed keys” is generic advice. It skips the part that matters most. Supabase’s service_role key isn’t a random string. It’s a JWT. That changes how you find it, and why a leak is so dangerous.

This walks through the Supabase-specific check. What the key looks like. Where it hides in a bundle. How to tell a real hit from a false positive. And the exact dashboard steps to rotate it.

Why the service_role key is different from every other Supabase key

Supabase issues two API keys per project. Both are JWTs. Both are signed with the same secret. That’s part of why people mix them up:

  • anon key: its payload says "role":"anon". It’s meant to sit in your frontend. Row Level Security (RLS) policies decide what it can actually touch.
  • service_role key: its payload says "role":"service_role". This key skips RLS entirely, on every table, for every request. It isn’t “an admin key.” It’s the master key.

A leaked anon key with RLS on is a non-event. A leaked service_role key is a different story. Anyone holding it can read, edit, or delete every row in your database. They can call any Postgres function. They can hit the Supabase Admin API and create new users with any permissions they want. There’s no partial version of this. It’s a full compromise of the project.

What the key actually looks like

Every Supabase API key is a JWT: three base64url chunks joined by dots, always starting with eyJ. The prefix alone won’t tell you anon from service_role, since both start the same way. The difference is inside the decoded payload.

The check

Step 1: pull every script your browser loads.

Open your deployed app, view source, and note every <script src="..."> URL. Or do it from the terminal in one pass:

curl -s https://your-app.example.com | grep -oE 'src="[^"]+\.js"' | \
  sed -E 's/src="//; s/"//'

Step 2: search the fetched JavaScript for a Supabase JWT shape.

for js in $(curl -s https://your-app.example.com | grep -oE 'src="[^"]+\.js"' | sed -E 's/src="//; s/"//'); do
  curl -s "https://your-app.example.com/$js" | \
    grep -oE 'eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' && \
    echo "  ^ found in $js"
done

A match here isn’t automatically bad news. This pattern catches both key types, and finding the anon key in your bundle is expected.

Step 3: decode the payload and check the role.

JWTs are base64url, not encrypted. Anyone can read the payload, no secret needed. Take the middle segment (between the two dots) and decode it:

echo "PASTE_THE_MIDDLE_SEGMENT_HERE" | base64 -d 2>/dev/null | python3 -m json.tool

Look at the "role" field in the output:

  • "role": "anon" — expected, this key is meant to be public. Next, confirm RLS is actually turned on (see below).
  • "role": "service_role" — this is a real, current exposure. Treat it as urgent, not “get to it this week.”

If you found a service_role key in your bundle

1. Rotate it now, in the Supabase dashboard. Go to Project Settings → API. Under “Project API keys,” reset the service_role key. This kills the old one instantly for every client using it. If your own backend uses that same key, update it there too, before you rotate.

2. Move the privileged call server-side. The service_role key should only ever live in a server environment variable: your own backend, or a Supabase Edge Function’s secret store. It should never ship to a browser. No VITE_, NEXT_PUBLIC_, or similar client-exposed prefix. If a frontend feature genuinely needs to bypass RLS, put it behind a server endpoint instead. That endpoint checks the caller’s permissions itself, then uses the service_role key internally.

3. Check Supabase’s own logs for damage. Go to Project → Logs → Postgres Logs and Auth Logs. Filter to the window since the leak likely started, such as your first deploy with the bad key. Look for queries or admin-API calls you don’t recognize. A compromised service_role key can also create new users through the Admin API, so check the Auth → Users list too.

4. Re-run the check against the new build. Confirm the rotated key shows up, and only the rotated key.

Why this specific leak happens on Supabase

Supabase’s own quickstart shows createClient(url, anonKey) on the frontend. That’s correct. The mistake creeps in later. A feature needs to skip RLS for a real reason: an admin dashboard, a batch import, a webhook handler built fast during a demo. The quickest way to make it work is swapping in the service_role key on whatever client is closest at hand. It works right away, so nobody flags it, and it ships.

It’s an easy mistake to make once and never notice, because nothing breaks. The app works the same either way. The only thing that changes is who else can now read your database.

Preventing it from happening again

The VITE_ / NEXT_PUBLIC_ prefix rule stops the obvious mistake: naming a variable so your build tool ships it to the browser on purpose. It doesn’t stop someone from hardcoding the raw key string straight into a component during a rush, which skips the prefix check entirely.

A cheap backstop: add the same grep from Step 2 as a pre-commit hook or a CI step that runs against your built output, not just your source. It fails the build if any Supabase JWT with "role":"service_role" shows up in a file meant for the browser. That catches both the prefix mistake and the hardcoded-string mistake, and it runs in seconds.

Or, we do it for you

This check is part of the free production audit we run on Supabase, Lovable, and similar backend-as-a-service apps. We grep your live bundle for both key types, decode and check the role on every match, and confirm RLS is actually on for every table, not just assumed. Get a free production audit, or see Secrets & Access Lockdown if you already know the key is exposed and want it rotated and moved server-side fast.

For the platform-agnostic version of this check, see check for exposed API keys in 5 minutes. If you’re sorting out which Supabase variables are safe to expose in the first place, see Lovable environment variables: what’s secret.

FAQ

Is a leaked anon key also a problem?

Not on its own, as long as RLS is on for every table. The anon key is meant to be public. Its power comes entirely from your RLS policies. Check with select tablename, rowsecurity from pg_tables where schemaname = 'public'; in the Supabase SQL editor. Any table showing false is wide open to anyone holding the anon key, which is anyone on the internet.

Can I tell service_role and anon apart just by looking at the key string?

No. Both are JWTs that start with eyJ, and the prefix won’t tell you which is which. You have to decode the middle segment and read the "role" field, which is exactly what the check above does.

Does rotating the key break my running app?

Briefly, for anything still using the old key. Any server process holding the old service_role key in its environment will start failing calls. Update it with the new key and redeploy or restart. Plan the rotation as a quick deploy, not a silent dashboard click.

What if my service_role key was only ever in a server environment variable, never in the frontend?

Then this check doesn’t apply to you. That’s the correct setup already. It’s still worth running the same decode step on your server’s env value, just to confirm it really is service_role and not something misconfigured.

Get a free production audit

15-point check, scored report back in 48 hours, free either way.

Start my audit