A signed JWT has a header, and that header is not protected by the signature. It can carry fields like kid, jku, and x5u that tell the server where to find the key that verifies the token. JWKS spoofing is what happens when a server trusts those attacker controlled fields to locate its verification key, because then the attacker gets to choose the key the server checks against, and a key the attacker chose is a key the attacker can sign with.
The header decides which key, and the header is not signed
A signed JWT is three base64url segments joined by dots: header.payload.signature. RFC 7515, JSON Web Signature, defines the header parameters a verifier may read to pick a key. Three of them point outward, at a key the server has to go and load:
kid, the key id. RFC 7515 calls it a hint indicating which key was used, and the structure of its value is left unspecified. A server uses it to pick one key out of many.jku, the JWK Set URL. It points at a URL that returns a JWKS, a JSON document holding one or more public keys.x5u, the X.509 URL. It points at a URL holding the X.509 certificate or chain for the signing key.
The signature covers the header and the payload. It does not cover where the key came from, because the key is the thing doing the covering. So the verifier reads these fields out of an unauthenticated header and uses them to decide what to trust. If it does that without question, the token is choosing its own verifier.
The token is handing the server an address and saying check me against whatever lives there. A safe server already knows its keys and never asks the token for directions.
JWKS spoofing through jku and x5u that point at the attacker
This is the cleanest form of the attack. The server fetches the verification key from the URL in the header. So the attacker stands up their own key pair, hosts the public half as a JWKS on a server they control, and puts that URL in jku.
The forged header and the matching malicious JWKS look like this:
# Forged JWT header
{
"alg": "RS256",
"typ": "JWT",
"kid": "evil-key-1",
"jku": "https://attacker.example/keys/jwks.json"
}
# JWKS hosted at that URL, holding the attacker's PUBLIC key
{
"keys": [
{
"kty": "RSA",
"kid": "evil-key-1",
"use": "sig",
"n": "0vx7agoebGcQSuuPiLJXZ......",
"e": "AQAB"
}
]
}
The attacker signs the token with the private key that matches that modulus. The server reads jku, fetches the attacker’s JWKS, finds the key whose kid matches, and verifies the signature against the attacker’s public key. It matches, because the attacker holds the private half. The token is accepted, and the attacker can set sub or role to anything. x5u is the same attack wearing a certificate instead of a raw JWK. RFC 7515 says implementations that support jku or x5u must use TLS when fetching, but TLS only proves you reached the host in the URL, and the attacker owns the host.
Attack two: kid path traversal and kid injection
Even a server that does not fetch remote URLs can be fooled through kid. The value is opaque, so naive code often plugs it straight into a file path or a database query to look up the key.
Path traversal to a predictable file
If the server reads a key from disk using kid as part of the path, the attacker can walk out of the keys directory and point at a file whose contents they can guess. The classic target is a file that is effectively empty or fully known:
# kid walks out of the key store and lands on an empty file
{ "alg": "HS256", "kid": "../../../../../../dev/null" }
On many systems /dev/null reads back as an empty string. If the server then treats the file contents as an HMAC secret, the secret is the empty string, and the attacker signs the token with an empty key. The signature matches, because both sides used nothing as the key. Any world readable file with stable contents works the same way once the attacker knows the bytes.
SQL injection in the kid lookup
When the key lookup is a database query, an unsanitized kid is a SQL injection point. The attacker crafts a kid whose injected query returns a value they control, and that returned value becomes the verification key. A union based payload can make the query hand back a string the attacker already knows, which they then use as the signing key.
Keys with guessable contents
The pattern under both of these is the same. If the attacker can steer kid at any key whose contents they can predict, an empty file, a static asset shipped with the app, a well known default, they sign with that value and the server accepts it. The key never had to be theirs. It only had to be knowable.
This is not algorithm confusion
JWKS spoofing is often confused with the JWT algorithm confusion attack, and they are different bugs with a different root cause. Algorithm confusion abuses the alg field: the attacker downgrades to alg:none, or flips RS256 to HS256 so the server reuses its RSA public key as an HMAC secret. There the key is the server’s own, and the trick is changing how it gets used. JWKS spoofing abuses the key location fields instead. The algorithm can stay honest at RS256 the whole time. What moves is which key the server loads, from kid, jku, or x5u, and the attacker supplies or predicts that key. One bug lies about the algorithm. The other lies about the key. A server can be vulnerable to one, both, or neither, so test for them separately.
Defenses
- Never trust jku or x5u without an exact allowlist. Compare the full URL against a short list of known issuer endpoints, host and path, before fetching anything. Reject everything else. Matching only the host invites open redirect and parser tricks, so pin the exact URLs.
- Treat kid as untrusted input. It is attacker controlled, so validate and normalize it. Map it through a fixed lookup table of known key ids rather than concatenating it into a file path or a query. If it does not match a known id, reject the token.
- Pin your keys. The verifier should already hold its trusted keys, loaded from configuration or a known JWKS the server fetches on its own schedule, never from a location the token names.
- Keep keys separate per algorithm. A key meant for RSA verification should not be reachable as an HMAC secret, which also closes the algorithm confusion path next door.
- Reject unexpected alg. Pin an explicit allowlist such as
["RS256"]on the verify call so a swapped algorithm is refused before any key lookup happens.
These failures sit close to broader authorization problems, since a forged token is usually a way to reach data or actions the user was never granted. It is worth reading what an access control vulnerability looks like to see where a spoofed token actually does its damage.
Why this rewards understanding the app
You do not find JWKS spoofing by replaying a fixed payload. You find it by reading a real token, seeing that the header carries kid or jku, and asking the quiet question the server should have asked itself: where does this key come from, and who got to choose it. The bug is an assumption, that the token would never lie about where its key lives, and the way to surface it is to test that assumption directly. That is the kind of assumption an autonomous researcher built to test an app’s assumptions and prove findings with evidence is meant to catch. You can read more about that approach on our about page.
Frequently asked questions
What is JWKS spoofing in a JWT?
It is an attack where a server trusts the JWT header to decide which key verifies the token. The header can carry kid, jku, and x5u fields that point at a key, and none of them are protected by the signature. If the verifier loads the key from where the token says, an attacker supplies or predicts that key and signs a token the server then accepts as genuine.
How is JWKS spoofing different from a JWT algorithm confusion attack?
Algorithm confusion abuses the alg field, downgrading to none or flipping RS256 to HS256 so the server misuses its own key. JWKS spoofing abuses the key location fields instead, so the algorithm can stay honest while the attacker changes which key gets loaded through kid, jku, or x5u. One lies about the algorithm, the other lies about the key. A server can be vulnerable to one, both, or neither.
Why can the kid header lead to key takeover?
The kid value is opaque and attacker controlled, so naive code drops it straight into a file path or a database query. A path traversal value like ../../../dev/null can point the server at an empty file, which becomes an empty signing secret the attacker can match. An injectable kid can make a SQL query return a value the attacker already knows, which then becomes the verification key.
How do you defend against JWKS spoofing?
Never fetch a key from a URL the token names unless that exact URL is on a short allowlist of known issuer endpoints. Treat kid as untrusted input, validate it, and map it through a fixed table of known key ids rather than building a path or query from it. Pin your trusted keys in configuration, keep keys separate per algorithm, and reject any unexpected alg before the key lookup runs.
