JWT: when the public key becomes the secret
A JSON Web Token is three pieces separated by dots:
header.payload.signature, each base64url. Header and payload are JSON
readable by anyone — a JWT is not encrypted, it is signed. All the
security rests on one point: that the server, on receiving the token, rechecks that
the signature is valid. This is where things go wrong.
Flaw 1: alg:none
The JWT spec defines an algorithm called none: it means "unsigned
token", intended for cases where integrity is guaranteed elsewhere. In the
header:
{ "alg": "none", "typ": "JWT" }
An alg:none token has an empty third part. The
problem arises when the verifying library reads the algorithm from the token's
own header and goes along with it. The attacker takes any token, changes the
payload ("role":"user" → "role":"admin"), sets
alg:none, deletes the signature:
base64url({"alg":"none","typ":"JWT"}) + "." +
base64url({"sub":"1","role":"admin"}) + "."
If the server verifies with something like jwt.verify(token)
without constraining the algorithm, it accepts the token as "correctly unsigned".
No secret, role check bypassed.
Flaw 2: RS256 → HS256 confusion
This one is subtler and surprises even careful developers. Two facts are needed:
- RS256 is asymmetric: the server signs with the private key and verifies with the public key. The public key is, by definition, public.
- HS256 is symmetric: the same secret key signs and verifies. It is an HMAC.
Picture a server that expects RS256 but uses a generic verify function that picks the algorithm from the header and takes the same key material as the "key" in both cases. The attacker:
- obtains the RS256 public key (often exposed on a JWKS endpoint, in a TLS certificate, or on GitHub);
- forges a token with header
alg:HS256and the payload they want; - computes HMAC-SHA256 of the token using the public key as the HMAC secret.
import jwt # PyJWT
public_pem = open('public.pem').read()
forged = jwt.encode(
{"sub": "1", "role": "admin"},
key=public_pem, # the PUBLIC key used as the HMAC secret
algorithm="HS256",
)
The server receives the token, reads alg:HS256 from the header,
takes the public key (which it already has, to verify RS256) and uses it as the
HMAC secret to recompute the signature. It matches. The token is accepted. The
attacker has signed a valid token knowing only public material.
The common root
Both flaws have the same cause: letting the token declare how it should
be verified. The alg field in the header is attacker input.
Trusting it to choose the verification algorithm is like asking the lock which key
to use.
The rule that closes everything
Always constrain the algorithm on the server side, with an explicit list, and ignore what the header says:
# right: the server decides, the token gets no say
jwt.decode(token, key=public_pem, algorithms=["RS256"])
# wrong: anything that leaves algorithms unset, or that accepts
# the list from the header, or that passes 'verify=False'
Corollaries worth writing into every checklist:
- Never use a "just verify" function that accepts any algorithm. Many legacy libraries did this by default; modern versions force you to pass the list, precisely because of this.
- Keep the key type separate from the algorithm type: an RSA key must not be able to end up in an HMAC function. Some libraries now explicitly reject that combination.
- Verify
exp,audandisstoo: a valid signature on an expired token, or one meant for another service, is not enough. - Where you can, prefer server-side opaque tokens (session id + store) when you do not actually need stateless verification. A JWT is a choice, not a default.