The Signature Was Always Theater
JWT alg:none, and why a token is only as honest as the server that checks it.
A JSON Web Token is a claim with a receipt stapled to it. The claim says who you are and what you’re allowed to do. The receipt, the signature, is the server’s promise that nobody changed the claim in transit. The whole scheme rests on one assumption: that the server actually checks the receipt, and checks it the way it expects to.
alg:none is what happens when it doesn’t.
Anatomy of a token
A JWT is three base64url segments joined by dots: header.payload.signature.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjYWRlIiwicm9sZSI6InVzZXIifQ.<signature>
Decode the first two and there’s no encryption here at all, just base64:
header → {"alg":"HS256","typ":"JWT"}
payload → {"sub":"cade","role":"user"}
The payload is readable by anyone. That’s fine by design; it’s not meant to be secret, only tamper-evident. The signature is what makes tampering detectable: change one byte of the payload and the signature no longer matches.
Note what’s sitting right there in the header, though: alg. The token tells the server which algorithm to use to verify it. The token. The thing the attacker controls.
The “none” algorithm
The JOSE standard (RFC 7515) defines a legitimate algorithm called none, an “Unsecured JWS.” It exists for cases where integrity is already guaranteed by some other layer, so the token carries no signature at all. A none token is just header.payload. (note the trailing dot and the empty third segment).
You can already see the problem. If the server decides how to verify a token by reading the alg field out of the token, then an attacker can simply tell it: don’t verify at all.
The forge
Take any valid token. Decode the payload, change the claims to whatever you want, set the header’s alg to none, and drop the signature:
# helper: base64url with no padding
b64url() { printf '%s' "$1" | base64 | tr '+/' '-_' | tr -d '='; }
H=$(b64url '{"alg":"none","typ":"JWT"}')
P=$(b64url '{"sub":"cade","role":"admin"}') # quietly promoted to admin
echo "${H}.${P}." # header.payload. <-- empty signature, trailing dot
Send that token. If the library honors none, the server reads role: admin, finds a valid (because unsecured) token, and believes it. No key. No cracking. You just asked it to stop checking, and it agreed.
Why this ships to production
It isn’t usually a dramatic mistake. It’s a default. Some JWT libraries historically accepted none unless you explicitly told them not to. Some expose a single verify(token, key) call that picks the algorithm from the token rather than from server configuration, so a token claiming none skips the key entirely. Mix in a confused deputy (a service that verifies with one library and re-issues with another) and you get a gap nobody owns.
The root cause is always the same shape: the server trusted an attacker-controlled field to tell it how much to trust the attacker.
The fix
- Pin the algorithm. Decide server-side which algorithms are acceptable (e.g. an allowlist of
RS256) and reject anything else (includingnone) before verification. - Never accept
noneon a protected endpoint. There’s no legitimate reason an authentication token should arrive unsecured. - Separate keys and roles. Don’t let a public verification key double as anything that can sign.
- Use current, maintained libraries and read their algorithm-handling docs specifically. This class of bug is old and well-patched, in libraries that are kept current.
The point
KernelHavoc doesn’t guess the algorithm. He asks the endpoint what it will accept, generation after generation, until something in the candidate set is a token that says none and means it. The server takes the claim because the claim looks valid, and it looks valid because validity was never really being enforced.
The signature was always theater. alg:none just stops pretending.