Bug Hunting Journey

Writeups Auth bypass

Auth bypass · critical

The kid header that accepted a path

Severity Critical CVSS 9.1
Impact
Forged tokens for any user, including administrators
Bounty
$2.5k - $5k
Program
Private

TL;DR

The kid header of the JWT was used directly as a path to load the verification key. Pointing it at a file with predictable contents let me sign my own tokens and be accepted as any user.

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:

JWT header, decodedJSON
{ "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.

Probing the loaderbash
# 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.

200OKresponse
HTTP/2 200 OKcontent-type: application/json{"user":{"id":1,"email":"████████████████","role":"owner"}}

Takeaways

  • kid is 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

  1. Report sent
  2. Emergency mitigation kid allowlist deployed the same evening
  3. Fix deployed
  4. Cleared for publication