Forging Admin: When a Leaked Secret Becomes a Root Login
A token is only as strong as its secret
A JWT is three base64url chunks: header, payload, signature. The signature protects the token from tampering, but authorization also depends on issuer, audience, expiry, and the application's authorization checks. If a signing secret leaks and an application trusts attacker-controlled claims such as role, an attacker may mint tokens the application accepts.
This is the step that turns a leak into a login. It is worth understanding exactly how short the path is.
Three ways the secret gets out
- In the bundle. A
NEXT_PUBLIC_orVITE_prefix ships the variable straight to the browser. People do this to signing secrets more often than you would think. Grep your built JavaScript for it before an attacker does. - Weak secret.
HS256with a predictable word or company name can be tested offline without network noise; recovery speed depends on the secret's entropy, wordlist, implementation, and hardware. - Algorithm confusion. Your tokens use
RS256(a public/private key pair), but a lax verifier also acceptsHS256. The attacker signs a token withHS256using your public key, which is public, as the HMAC secret. A verifier told to "just verify" accepts it.
Where do secrets like this leak from in the first place? Usually the file in The .env File That Ends Companies.
From secret to admin, in one decode
Take a normal session token and look at the payload:
{ "sub": "user_77", "role": "user", "iat": 1756300000, "exp": 1756386400 }Change one field, re-sign with the leaked secret, and you are done:
import jwt # PyJWT
payload = {"sub": "user_77", "role": "admin", "iat": 1756300000, "exp": 1756386400}
forged = jwt.encode(payload, "leaked-secret-here", algorithm="HS256")
print(forged)In an application that authorizes directly from the role claim, the server may check the signature, read role: admin, and grant administrator access without an admin password. Robust applications also validate issuer, audience, expiry, and server-side authorization state.
Every session token you have ever issued shares one secret. When that secret leaks, you are not exposed to one forged login, you are exposed to unlimited forged logins as any user, any role, until you rotate. Rotation is the only real remedy.
How to tell if you are exposed
You do not need to wait to be attacked to find out. Three quick checks cover most of the risk:
- Grep your shipped bundle. From your built front-end, search for the secret and for tell-tale prefixes. If anything comes back, the secret is already public and rotation is not optional.
# Run against your production build output, not your source
grep -rIE 'JWT_SECRET|SUPABASE_JWT|NEXT_PUBLIC_.*SECRET|VITE_.*SECRET' dist/ .next/- Decode a real token. Paste one of your own session tokens into any offline base64 decoder and read the header. If
algisHS256and your backend also issuesRS256elsewhere, you have the ingredients for algorithm confusion. - Test the weak-secret case. If the header says
HS256, a wordlist can be tested offline. If a common word cracks it, treat the signing key as compromised.
None of these touch another user's data. They tell you whether the front door is already unlocked.
Proving it without doing damage
You do not break anything to prove this. You forge a token with elevated claims and hit a read-only privileged endpoint:
GET /api/admin/me HTTP/1.1
Host: app.yourco.com
Authorization: Bearer eyJhbGci...forged-admin-tokenA 200 that returns an admin context in the body is the evidence: request in, response out, nothing mutated. That saved pair is the difference between "theoretically exploitable" and "here is the proof." Once you can forge a session, the natural next target is data an ordinary user should never touch, which is exactly the Supabase RLS Gaps an attacker chains to next.
Shutting it down
| Failure | What the attacker does | Fix |
|---|---|---|
| Secret in the bundle | Reads it from your JS | Never prefix a secret as public |
Weak HS256 secret | Cracks it offline | 32+ random bytes from a CSPRNG |
| Algorithm confusion | Signs with your public key | Pin an algorithms allowlist |
Concretely:
- Rotate the secret now if there is any chance it leaked. Rotation invalidates every forged token along with the real ones.
- Use a long, random secret, never a word. Thirty-two bytes from a real random source, minimum.
- Pin the algorithm. Tell your verifier
algorithms=["RS256"]explicitly and reject everything else. This kills both algorithm confusion and thealg: nonetrick in one line. - Keep signing secrets server-side. If it can reach a browser, it is not a secret.
The IETF JWT best-current-practices guidance requires an explicit algorithm allow-list; PyJWT documents 32-byte minimum keys for HS256 in its usage guide. Notra forges a token against your own endpoints, non-destructively, and shows the exact request and response if one gets in. Run a free scorecard, or read more of our field notes on the blog.