The anon key and the service_role key
One is a public identifier constrained by row-level security. The other bypasses every policy you have ever written. They look almost identical.
Two tokens, one shape
Both begin eyJ. Both are three base64url segments separated by dots. Both are roughly the same length, both look like noise, and in a minified bundle both appear as a long string assigned to a variable with a name the minifier invented.
One of them is designed to be in your frontend. The other grants unrestricted access to your entire database.
There is no visual difference. You cannot tell them apart by looking, and neither can a reviewer skimming a diff, which is precisely why the wrong one ends up shipped. The difference is inside the token, and reading it takes one command.
Reading the role claim yourself
Decoding the payload
A JSON Web Token is three parts: a header, a payload and a signature. The first two are base64url-encoded JSON. Base64 is an encoding, not encryption — the payload is readable by anyone, with no key of any kind.
echo 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.EXAMPLE_PAYLOAD.EXAMPLE_SIG' \
| cut -d. -f2 | tr '_-' '/+' | base64 -d
For a Supabase token the result contains the claims that matter:
{ "iss": "supabase", "ref": "EXAMPLE-PROJECT", "role": "anon", "exp": 2000000000 }
Read role. anon and authenticated belong in a browser. service_role does not, under any circumstances.
Two other claims are worth noting while you are in there. ref is the project reference, which tells you which of your projects a stray token belongs to — useful when you are looking at a bundle from a service you did not build. And an iss of supabase-demo marks the local development default that supabase start generates identically on every machine; it is the same on every developer's laptop in the world, it grants nothing, and it should never be treated as a leak.
Why nobody verifies the signature
The natural objection is that an unverified token proves nothing — anybody can craft a payload claiming any role.
True, and irrelevant here. The question being asked of a string scraped out of a minified bundle is not "is this token valid", it is "what does this token claim to be". Verification would require the project's signing secret, which is not available and should not be. And the two possible cases both matter: either the token is genuine, in which case you have a full database compromise, or it is a forgery that Supabase will reject, in which case somebody put a fabricated service-role token in your frontend and you would also like to know that.
So the claim is taken at face value, deliberately, and the severity follows from what it says about itself.
A
service_rolekey in the client bundle is a full database compromise waiting to happen.
What a leaked service_role key gives someone
Every row in every table
The service-role key bypasses row-level security entirely. Not "has broad policies" — the policy layer is not consulted. Every table in every schema exposed through the API is readable in full by anyone holding it, regardless of how carefully your policies were written.
This is worth dwelling on because it inverts the usual reasoning about blast radius. Normally an exposed credential grants what it was scoped to grant, and a careful team limits the damage in advance by scoping tightly. Here the scoping mechanism is the thing being bypassed. All the policy work you did is still correct and is simply not being asked.
Every write, including deletes
Reads are the half people think about. The key also writes: insert, update, delete, on any table, without constraint. It can modify the rows that drive your authorisation logic, and depending on your schema it can create records that other systems downstream trust because they came from your database.
And the project reference tells them where
Because the ref claim is in the payload, an exposed token is self-locating. Nobody has to work out which project it belongs to; the token says so, and the API endpoint follows directly from the reference. This is why automated collection finds these quickly — the string is both the credential and the address.
If you find one, the order is not negotiable. Rotate at the provider first, before changing any code. The value is already public; removing it from the next build does nothing about the copies already served, and cached or archived chunks may keep serving the old file for some time.
Common questions
How do I check my own bundle for this?
Fetch your deployed JavaScript and search for the prefix, then decode anything you find.
curl -s https://example-app.test/_next/static/chunks/*.js \
| grep -oE 'eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+'
Every hit is a JWT. Decode each one and read the role. Most will be your anon key, which is fine and expected.
What does a scanner store when it finds one?
Not the value. A well-behaved report keeps a masked form — a short head and tail with the middle removed, so the owner can recognise the key in their dashboard without the report containing anything usable — plus a salted fingerprint that gives the finding a stable identity across scans. The masking has to be proportional: revealing twelve characters of a 164-character key is fine, and revealing twelve characters of a sixteen-character token is not redaction at all.
My key starts with sb_secret_ rather than eyJ. Is that the same thing?
Same consequence, newer format. Supabase's 2024 key scheme replaces the JWT pair with sb_publishable_ for the browser and sb_secret_ for the server. There is no payload to decode, because the prefix is the whole claim — which is an improvement, since it is legible at a glance rather than requiring a decode step.