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

  1. How to recognize a JWT (including the quick “eyJ” tell)
  2. How to decode a JWT in your terminal (header & payload)
  3. The anatomy of a JWT: header, payload (claims), signature
  4. Common algorithms (HS256, RS256, ES256, EdDSA)
  5. Where JWTs show up in APIs (Bearer tokens, OIDC)
  6. Misconfigurations to look for + a safe Docker lab to try locally.
  7. 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 with eyJ…. If you see a long string with two dots and it starts with eyJ, 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
  • 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"}), sometimes kid (key id).
  • Payload (claims) — info being conveyed:
    • Common claims: iss (issuer), aud (audience), sub (subject), exp (expires), nbf, iat, jti.
  • 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, exp and 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.

  1. Accepting alg: none

    • Symptom: Server accepts unsigned tokens.
    • Fix: Disallow none; require signed tokens with allow-listed algorithms.
  2. Algorithm confusion (HS⇄RS)

    • Symptom: Server trusts alg from the token and uses the wrong key type.
    • Fix: Pin expected algorithms/keys in code/config; never let the token decide.
  3. 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.
  4. 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.
  5. Claim validation gaps

    • Symptom: Not checking iss/aud/exp/custom claims (and optionally nbf/iat/jti).
    • Fix: Enforce strict claim checks; add clock-skew tolerance.

Practice legally at PortSwigger Web Security Academy and review OWASP WSTG guidance.


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:

  • valid200 with { ok: true }
  • Others → 401 with 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 Academy and review OWASP WSTG.


10) Quick hardening checklist

  • Pin algorithms (don’t trust alg from the token)
  • Disallow none (require signatures)
  • Validate claims: iss, aud, exp , custom claims (+ nbf, iat, jti if 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.