Every JWT carries a header parameter whose only job is to tell the server which key to use. The RFC does not define the format of that parameter. Most libraries do not validate what arrives in it.
The kid parameter is processed before signature verification. When a server passes that value directly to a SQL query or file read without sanitization, attackers do not need to break cryptography. They redirect key selection to a value they control.
kid Is a Lookup Hint the Spec Left Unguarded
RFC 7517 Section 4.5 defines kid as a case-sensitive string with no required format. The spec text is explicit: the structure is "the developer's choosing", with no UUID requirement, no path restriction, and no character allowlist mandated. The spec assumes library authors will validate, but provides no enforcement mechanism.
The server decides what to do with the kid value. In practice, that means querying a database or reading a file using the raw header value. The JWT header is Base64url-decoded before signature verification, so kid is processed before any trust is established.
Most HMAC libraries accept whatever string the server returns as the verification key. The problem is not in the cryptography. It is in the path between the decoded header and the key lookup; in that path, attackers can substitute the key.
SQL Injection via kid: Controlling What the Database Returns
The vulnerable pattern appears in code like this:
SELECT key FROM keys WHERE kid = '<value_from_header>'
Direct string concatenation, no parameterization. The attacker injects:
' UNION SELECT 'attacker_secret'-- -
The database returns the literal string attacker_secret. The server uses that value as the HMAC key for signature verification. The attacker already knows the key because they chose it.
They re-sign the forged payload with the same value and verification passes. The server detects nothing unusual: the signature is mathematically correct, and the key returned from the database matches the key used to sign. The problem is that both originate from the attacker.
The OR variant is simpler: ' OR '1'='1 returns the first row in the keys table. If the attacker knows which key is first, the attack works the same way. In databases without predictably ordered indexes, the attacker can try ORDER BY 1 to force consistency.
CVSS 3.1 for this class is 7.5 High (Invicti, CWE-89 + CWE-287). The attack is network-accessible, requires no prior authentication, and has full impact on authentication integrity.
The attack has four steps. The attacker decodes the JWT header and injects the SQL payload into the kid field. They re-sign the token with the injected string and send it. The server accepts it.
Path Traversal via kid: Redirecting to a Predictable File
The vulnerable pattern on the filesystem:
const key = fs.readFileSync(`/keys/${kid}`);
No path.basename(), no allowlist validation. The attacker injects:
../../../../dev/null
The server navigates to /dev/null, the Linux pseudo-device that always returns zero bytes. The content read is an empty sequence. The derived HMAC key is a zero-byte sequence.
The attacker creates a token with the JWK k parameter set to AA==. AA== is the Base64url encoding of a null byte, the value most libraries derive when reading /dev/null. They sign the forged payload with that key. The server reads /dev/null, gets the same empty result, and verification passes.
The mechanism works because the server and the attacker arrive at the same value by different paths. The server reads a file that is always empty. The attacker signs with that same null byte as the HMAC key. The result is identical because the key is the same: nothing.
The PortSwigger lab documents the attack step by step: set kid to ../../../../dev/null, change sub to administrator, sign with k equal to AA==. The result is full access to /admin. CVSS 3.1 is also 7.5 High, CWE-287.
kid as Entry Point to the jku/x5u Attack Chain
kid does not need to work alone to escalate impact. Combined with an unvalidated jku header, it enables full key substitution from an attacker-controlled endpoint.
The jku (JWK Set URL) header instructs the server to download the public key set from a URL. If the server does not validate that the URL belongs to an authorized domain, the attacker hosts their own JWKS endpoint. kid then selects the specific key within that attacker-controlled set.
CVE-2018-0114 (node-jose < 0.11.0, CVSS 8.1 High) exposes the same trust-boundary failure. The library trusted the JWK embedded in the token header for verification. The attacker embedded their public key, signed with the corresponding private key, and verification passed. The structural failure is identical to the jku attack: the library delegated key selection to a value the attacker controlled.
The attacker generates an RSA key pair and hosts a JWKS at their endpoint. They set jku to that URL and kid to the hosted key ID, then sign the forged token with the generated private key. The server downloads the JWKS, finds the matching kid, and verifies with the attacker's public key. The signature is valid because the attacker holds the corresponding private key.
The primary defense against jku is a domain allowlist. Without it, kid and jku together form a complete key-substitution primitive.
Detection in API Traffic
kid manipulation patterns have detectable signatures in traffic. Path separator characters (../, %2F, %2e%2e) in the kid field indicate traversal attempts. SQL metacharacters (', ", --, UNION, SELECT, OR) indicate injection attempts.
Most WAFs do not inspect JWT headers by default. The kid field arrives Base64url-encoded, so the scanner must decode the header before applying detection rules. Inspecting the raw HTTP field does not catch these attacks.
The MAGO Intel tool (intel.mago.team) analyzes JWT header parameters during API reconnaissance. The scanner extracts the raw kid value and checks the algorithm in use. HS256 is more susceptible to these attacks than RS256. It flags values containing traversal sequences or SQL metacharacters. Detection at the scanner layer does not replace input validation at the code layer. It identifies attack surfaces before active exploitation.
Fix: Three Controls That Close the kid Attack Surface
The first control is the parameterized query. Never concatenate kid into SQL:
PreparedStatement stmt = conn.prepareStatement(
"SELECT key FROM keys WHERE kid = ?"
);
stmt.setString(1, kid);
User input never touches the query structure. The database treats kid as data, not as code.
The second control is path.basename() plus allowlist. Reject any kid that contains /, \\, .., %2f, or %2e. Apply path.basename(kid) and validate the result against a list of known key IDs:
const allowedKids = new Set(['key-2024-01', 'key-2024-02']);
const safeKid = path.basename(kid);
if (!allowedKids.has(safeKid)) throw new Error('Invalid kid');
The regex ^[a-zA-Z0-9_-]{1,64}$ covers most cases. UUID format is preferred. Reject on mismatch, never fall through to a default key: a fallback key is another attack surface.
The third control is algorithm pinning. Verify the alg claim before the key lookup. RS256 and ES256 are immune to the empty HMAC key variant because attackers cannot forge an RSA or ECDSA signature with a public key. Key management services (AWS KMS, HashiCorp Vault) eliminate file-based and database-based key lookup. The kid maps to a key alias with no raw SQL or file path involved.
The kid parameter is not a vulnerability in the JWT spec. It is a trust assumption the spec encodes and most codebases inherit without review. The fix costs ten lines of code. The exploit costs one modified header.
Top comments (0)