JWT Tokens — Structure, Creation, and Validation

9 min read

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:

  1. Header — what algorithm signed this token.
  2. Payload — the claims (data) in the token.
  3. 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:

ClaimMeaningExample
subSubject — who the token is about"user_42"
issIssuer — who created the token"https://auth.example.com"
audAudience — who the token is for"orders-api"
iatIssued at — Unix timestamp1715000000
expExpiry — Unix timestamp1715003600
nbfNot before — Unix timestamp1715000000
jtiJWT 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:

  1. Format — is it three base64url parts separated by dots?
  2. Algorithm — is the alg field one we accept?
  3. Signature — recompute it from header + payload + secret, compare to what was sent.
  4. Expiry — is exp in the future? Is nbf in the past?
  5. Issuer / audience — does iss match the auth server we trust? Does aud match 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:

ScenarioWhat it checksExpected
Fresh, valid tokenHappy path200
Expired token (exp in the past)Expiry check401
Token with nbf in the futureNot-before check401
Token with wrong issueriss validation401
Token with wrong audienceaud validation401
Token signed with the wrong secretSignature check401
Token with a tampered payloadSignature check401
Random string masquerading as a JWTFormat check401
Token with alg: "none"Insecure algorithm401
Valid token, insufficient role/permissionsAuthorisation, not authentication403

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:

  1. Get a real, valid JWT (from a login response).
  2. Paste it into jwt.io.
  3. Edit a claim in the payload (change "role": "user" to "role": "admin").
  4. Copy the resulting token from the left pane.
  5. 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 exp test. "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.

  1. 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).
  2. Get a JWT and paste it into jwt.io. Identify each claim. What's sub? What's exp? Convert exp to a date — when does the token expire?
  3. Use curl to call a protected endpoint with the token. Confirm 200.
  4. In jwt.io, change one claim in the payload (e.g. email to a different value, or role if the app has roles). Copy the new token from the left pane.
  5. 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.
  6. Wait until the original token expires (or use jwt.io's debugger to set exp to a past timestamp and re-sign with a known secret if you have one). Send it. Confirm 401.
  7. 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.

// tip to track lessons you complete and pick up where you left off across devices.