Who this is for: Newcomers to cybersecurity who want a safe, hands-on way to understand JWTs.
What you’ll build: A tiny Docker lab that shows JWT validation—so you can test valid vs invalid tokens.
What you’ll learn
- How to recognize a JWT (including the quick “
eyJ” tell) - How to decode a JWT in your terminal (header & payload)
- The anatomy of a JWT: header, payload (claims), signature
- Common algorithms (HS256, RS256, ES256, EdDSA)
- Where JWTs show up in APIs (Bearer tokens, OIDC)
- Misconfigurations to look for + a safe Docker lab to try locally.
- Try-it-yourself: generate valid and intentionally invalid tokens to see the server’s responses
1) How to spot a JWT (fast)
- Shape: Three Base64URL-encoded chunks separated by dots:
xxxxx.yyyyy.zzzzz // header.payload.signature - The “
eyJ” trick: Most JWT headers start with{"and Base64URL-encoding that begins witheyJ…. If you see a long string with two dots and it starts witheyJ, it’s likely a JWT (heuristic, not a guarantee). - Where JWTs travel: Commonly in:
- HTTP headers →
Authorization: Bearer <JWT> - Cookies →
a_cookie=... - POST bodies → REST / GraphQL requests
- HTTP headers →
- Quick helper: The online debugger at jwt.io can decode header/payload. Don’t paste secrets/production tokens.
2) Decode a JWT in your terminal (reading ≠ verifying)
Decoding just reads JSON. It does not prove the token is valid or untampered.
# Linux/macOS (GNU base64 uses -d; BSD/macOS base64 uses -D; this uses -d)
TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZSIsImlzcyI6Imh0dHBzOi8vaXNzdWVyLmV4YW1wbGUiLCJleHAiOjE3MzY0ODM0NTF9.xxxxxx'
# Header
echo "$TOKEN" | cut -d '.' -f1 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
# Payload (claims)
echo "$TOKEN" | cut -d '.' -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
Why tr? JWTs use Base64URL, which swaps +→- and /→_.
3) JWT anatomy: header, payload, signature
- Header — metadata (e.g.,
{"alg":"HS256","typ":"JWT"}), sometimeskid(key id). - Payload (claims) — info being conveyed:
- Common claims:
iss(issuer),aud(audience),sub(subject),exp(expires),nbf,iat,jti.
- Common claims:
- Signature — proves integrity:
Sign( base64url(header) + "." + base64url(payload), key, alg )
4) Algorithms
- HS256/384/512 (HMAC): One shared secret signs & verifies. Simple, but every verifier must protect the same secret.
- RS256/384/512 (RSA) & PS256/384/512 (RSA-PSS): Private key signs, public key verifies. Easier key distribution/rotation.
- ES256/384/512 (ECDSA): Shorter signatures; strong security in good implementations.
- EdDSA (Ed25519): Deterministic, fast, compact (library support varies).
Rule of thumb: For APIs, prefer asymmetric (RSA/ECDSA/EdDSA) when possible; it avoids sharing one secret across many services.
5) Where JWTs show up in APIs
- Bearer auth: Clients send
Authorization: Bearer <JWT>; servers must validate signature and claims (iss,aud,expand custom claims). - OpenID Connect: ID Tokens are JWTs; some providers also use JWTs for access tokens.
6) Misconfigurations to watch for (and how to fix)
These are common in real apps and training labs. We’ll focus on detection & hardening.
-
Accepting
alg: none- Symptom: Server accepts unsigned tokens.
- Fix: Disallow
none; require signed tokens with allow-listed algorithms.
-
Algorithm confusion (HS⇄RS)
- Symptom: Server trusts
algfrom the token and uses the wrong key type. - Fix: Pin expected algorithms/keys in code/config; never let the token decide.
- Symptom: Server trusts
-
Weak HMAC secrets / leaked keys
- Symptom: Short or guessable secrets (e.g.,
secret), or keys committed to repos. - Fix: Use long, random secrets via KMS; rotate; monitor verification failures.
- Symptom: Short or guessable secrets (e.g.,
-
Untrusted key loading (
kid/jku)- Symptom: Loading keys from user-controlled paths/URLs.
- Fix: Only load keys from allow-listed JWKS; sanitize
kid; pin issuers.
-
Claim validation gaps
- Symptom: Not checking
iss/aud/exp/custom claims(and optionallynbf/iat/jti). - Fix: Enforce strict claim checks; add clock-skew tolerance.
- Symptom: Not checking
Practice legally at
PortSwigger Web Security Academyand reviewOWASP WSTGguidance.
7) Safe Docker lab (defensive demo)
This lab returns 200 for a valid token and 401 for invalid ones (expired, wrong audience/issuer, bad signature). It demonstrates correct server-side validation without bypass techniques.
Stack: Node.js + Express + jose
7.1 Run the lab
Clone the repo from https://github.com/CyberSquirrel-AI/jwt-security-testing-lab section or create these files:
# 1) Clone the repo from https://github.com/CyberSquirrel-AI/jwt-security-testing-lab section or create these files:
# docker-compose.yml
version: "3.8"
services:
api:
build: ./api
ports: ["8080:8080"]
environment:
- JWT_ISS=https://issuer.example
- JWT_AUD=https://api.example
- JWT_HS_SECRET=use-a-long-random-secret-and-store-secret-in-vault
# api/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 8080
CMD ["node","server.js"]
# api/package.json
{
"name": "jwt-defensive-lab",
"private": true,
"type": "module",
"dependencies": {
"express": "^4.19.2",
"jose": "^5.9.3"
}
}
# api/server.js
import express from "express";
import { jwtVerify } from "jose";
const ISS = process.env.JWT_ISS;
const AUD = process.env.JWT_AUD;
const HS_SECRET = new TextEncoder().encode(process.env.JWT_HS_SECRET);
const app = express();
app.use(express.json());
async function verifyToken(bearer) {
const token = bearer?.split(" ")[1];
if (!token) throw new Error("No bearer token");
return jwtVerify(token, HS_SECRET, {
algorithms: ["HS256"], // pin algorithm(s)
issuer: ISS,
audience: AUD,
clockTolerance: "5s"
});
}
app.get("/protected", async (req, res) => {
try {
await verifyToken(req.headers.authorization);
res.json({ ok: true, message: "✅ Access granted" });
} catch (e) {
res.status(401).json({ ok: false, error: e.message });
}
});
app.listen(8080, () => console.log("API listening on :8080"))
# 2) Build & run
docker compose up --build
8) Try it yourself: generate test tokens
Create a small script to produce one valid token and a few invalid ones (expired, wrong audience/issuer, bad signature). You’ll use them to call the lab endpoint and observe responses.
# scripts/make-tokens.mjs
import { SignJWT } from "jose";
const ISS = process.env.JWT_ISS || "https://issuer.example";
const AUD = process.env.JWT_AUD || "https://api.example";
const SECRET = new TextEncoder().encode(process.env.JWT_HS_SECRET || "use-a-long-random-secret-from-kms");
async function buildToken({ iss = ISS, aud = AUD, expSeconds = 600, secret = SECRET } = {}) {
const now = Math.floor(Date.now() / 1000);
return await new SignJWT({ sub: "user123", role: "reader" })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuer(iss)
.setAudience(aud)
.setIssuedAt(now)
.setExpirationTime(now + expSeconds)
.sign(secret);
}
const wrongSecret = new TextEncoder().encode("this-is-the-wrong-secret");
const tokens = {
valid: await buildToken(),
expired: await buildToken({ expSeconds: -60 }),
wrong_audience: await buildToken({ aud: "https://someone-else" }),
wrong_issuer: await buildToken({ iss: "https://evil.example" }),
bad_signature: await buildToken({ secret: wrongSecret })
};
for (const [name, tok] of Object.entries(tokens)) {
console.log(`\n# ${name}\n${tok}\n`);
}
Run it and test:
# Use the same env as the API for "valid"
export JWT_ISS=https://issuer.example
export JWT_AUD=https://api.example
export JWT_HS_SECRET=use-a-long-random-secret-from-kms
node scripts/make-tokens.mjs > tokens.txt
API=http://localhost:8080/protected
# Paste each token after 'Bearer ' to see responses:
curl -s -H "Authorization: Bearer <valid>" "$API" | jq .
curl -s -H "Authorization: Bearer <expired>" "$API" | jq .
curl -s -H "Authorization: Bearer <wrong_audience>" "$API" | jq .
curl -s -H "Authorization: Bearer <wrong_issuer>" "$API" | jq .
curl -s -H "Authorization: Bearer <bad_signature>" "$API" | jq .
Fully Automated
git clone git@github.com:CyberSquirrel-AI/jwt-security-testing-lab.git
cd jwt-security-testing-lab
docker compose build --no-cache
docker compose up
In a new terminal window
cd jwt-security-testing-lab
./test-tokens.sh
Expected:
valid→200with{ ok: true }- Others →
401with clear error messages
9) Burp Suite helpers (beginner-friendly)
- JWT Editor
BApp Store: Decode/view/edit tokens within Burp (for authorized testing). - JSON Web Tokens
BApp Store: Highlights JWTs in traffic; gives a dedicated editor. - Decoder tab (built-in): Paste any JWT segment to Base64-decode header/payload.
Only test systems you own or are authorized to assess. For safe, legal practice:
PortSwigger Web Security Academyand reviewOWASP WSTG.
10) Quick hardening checklist
- ✅ Pin algorithms (don’t trust
algfrom the token) - ✅ Disallow
none(require signatures) - ✅ Validate claims:
iss,aud,exp,custom claims(+nbf,iat,jtiif needed) - ✅ Use strong keys & rotate via KMS/JWKS; avoid hardcoding secrets
- ✅ Sanitize key lookups (
kid), allow-list JWKS hosts, pin issuers - ✅ Monitor verification failures & clock skew
Attribution
Ideas are mine but rephrased and edited using AI.