If you test modern APIs, you'll meet JWTs more often than any other token format. JWT (JSON Web Token, pronounced "jot") is the standard way to represent a signed, self-contained credential — a piece of JSON wrapped in a tamper-proof envelope. This lesson explains the three-part anatomy of a JWT, how to read one without any tooling, and the specific tests that catch the most common JWT bugs.
Anatomy: three dots, three parts
A JWT is a single string with two dots in it:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwibmFtZSI6IkFsaWNlIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzE1MDAwMDAwfQ.signature_bytes_here
Split on the dots and you get three parts:
- Header — what algorithm signed this token.
- Payload — the claims (data) in the token.
- Signature — proof the token wasn't tampered with.
Each part is base64url-encoded JSON. Decode them and you get readable text.
The three parts of a JWT, decoded
Header
{ "alg": "HS256", "typ": "JWT" }
Says which algorithm signs the token (HS256, RS256, ES256...) and that it's a JWT.
Base64url-encoded — readable
Anyone who has the token can decode the header. It is not a secret.
Payload
{ "sub": "user123", "role": "admin", "exp": 1715000000 }
The claims — who the token is about, what they can do, when it expires.
Also base64url-encoded — readable
Never put passwords or secrets in the payload. Anyone can decode it.
Signature
HMAC-SHA256(header.payload, secret)
Cryptographic hash of the first two parts plus a secret only the auth server knows.
Tamper-proof
Change a byte of the header or payload and the signature stops matching. The server rejects the token.
The crucial property: you can read a JWT without the secret. You cannot forge a JWT without the secret. That asymmetry is what makes JWTs useful.
Decoding a JWT in your head (almost)
In practice, paste any JWT into jwt.io and you'll see all three parts decoded. It's the single most useful tool for debugging auth issues — keep a tab open while testing JWT-protected APIs.
You can also decode in the terminal:
echo "eyJzdWIiOiJ1c2VyMTIzIn0" | base64 -d
# {"sub":"user123"}(Real tokens use base64url encoding, which swaps +/ for -_; base64 -d works on most short payloads but fails on edge cases.)
Common claims you'll see
The JWT spec defines a set of standard claim names — short three-letter codes you'll meet in almost every token:
| Claim | Meaning | Example |
|---|---|---|
sub | Subject — who the token is about | "user_42" |
iss | Issuer — who created the token | "https://auth.example.com" |
aud | Audience — who the token is for | "orders-api" |
iat | Issued at — Unix timestamp | 1715000000 |
exp | Expiry — Unix timestamp | 1715003600 |
nbf | Not before — Unix timestamp | 1715000000 |
jti | JWT ID — unique token id | "a1b2c3" |
Plus any custom claims the issuer adds: role, permissions, email, tenant_id, etc. These are application-specific.
How JWTs are validated
When the server receives a JWT, it does these checks in order:
- Format — is it three base64url parts separated by dots?
- Algorithm — is the
algfield one we accept? - Signature — recompute it from header + payload + secret, compare to what was sent.
- Expiry — is
expin the future? Isnbfin the past? - Issuer / audience — does
issmatch the auth server we trust? Doesaudmatch this API?
Any failure → 401. The endpoint never runs.
Test cases for JWT-protected endpoints
Each validation step is a thing you should test. The full matrix:
| Scenario | What it checks | Expected |
|---|---|---|
| Fresh, valid token | Happy path | 200 |
Expired token (exp in the past) | Expiry check | 401 |
Token with nbf in the future | Not-before check | 401 |
| Token with wrong issuer | iss validation | 401 |
| Token with wrong audience | aud validation | 401 |
| Token signed with the wrong secret | Signature check | 401 |
| Token with a tampered payload | Signature check | 401 |
| Random string masquerading as a JWT | Format check | 401 |
Token with alg: "none" | Insecure algorithm | 401 |
| Valid token, insufficient role/permissions | Authorisation, not authentication | 403 |
Last two need extra commentary:
alg: "none"exploit — the JWT spec defines a "none" algorithm meaning "this token is unsigned." Some libraries (a long time ago) accepted such tokens. If your server does too, anyone can forge any token. This bug is a CVE waiting to be filed; test for it explicitly.- Tampered payload — change
"role": "user"to"role": "admin", re-encode, and send. The signature won't match and the server should reject it. If it doesn't, you've found a real vulnerability.
Forging a tampered token to test
You can simulate a tampered token in two minutes:
- Get a real, valid JWT (from a login response).
- Paste it into jwt.io.
- Edit a claim in the payload (change
"role": "user"to"role": "admin"). - Copy the resulting token from the left pane.
- Send it back to your API.
A correctly-secured API rejects the tampered token with 401. A broken one accepts it — and that's a critical bug.
Refresh tokens
Access JWTs are typically short-lived (15-60 minutes) so leaks have a small blast radius. To avoid making the user re-authenticate every hour, the auth server also issues a refresh token — a longer-lived credential whose only purpose is to get new access tokens.
POST /oauth/token
grant_type=refresh_token&refresh_token=<your-refresh-token>The auth server returns a new access token (and usually a new refresh token, rotating the old one). Refresh-token rotation should be tested: an old refresh token should not work after it's been used, otherwise stolen refresh tokens are usable forever.
Where to keep tokens during tests
Same rules as the previous lesson:
- Read tokens from environment variables, never hardcode.
- Don't print full tokens in logs — first six chars and "..." is enough for debugging.
- Rotate test credentials regularly.
A common pattern: a pytest fixture (or equivalent) that fetches a fresh token at session start, caches it, and exposes it to every test. We cover that in Chapter 8.
⚠️ Common mistakes
- Treating the payload as encrypted. It's only encoded. Anyone can read it. Never put passwords, internal IDs you don't want exposed, or sensitive PII in the payload.
- Skipping the
exptest. "We trust our auth server" doesn't help when the auth server emits a 7-day-expiry token by mistake. Every protected endpoint should reject expired tokens. - Confusing 401 (auth) and 403 (permissions). A valid JWT for the wrong role should yield 403, not 401. Mixing them up makes diagnostics painful.
🎯 Practice task
Decode and tamper with a real JWT. 25-30 minutes.
- Sign up for a free Auth0 / Clerk / Firebase tenant, or pick any API where you can get a real JWT (some demo APIs return them on
POST /login). - Get a JWT and paste it into jwt.io. Identify each claim. What's
sub? What'sexp? Convertexpto a date — when does the token expire? - Use curl to call a protected endpoint with the token. Confirm 200.
- In jwt.io, change one claim in the payload (e.g.
emailto a different value, orroleif the app has roles). Copy the new token from the left pane. - Send that tampered token to the same endpoint. The expected response is 401 — if it returns 200, you've found a real signature-verification bug worth raising loudly.
- Wait until the original token expires (or use jwt.io's debugger to set
expto a past timestamp and re-sign with a known secret if you have one). Send it. Confirm 401. - Stretch: browse the API Testing Concepts cheat sheet and find at least one auth scenario you didn't test. Add it to your matrix.
You can now read, debug, and stress-test JWTs. The next lesson pulls authentication and authorisation testing together into a complete strategy.