JSON Web Tokens (JWT)
Sources: RFC 7519 – JWT · RFC 7517 – JWK · SuperTokens – Understanding JWKS
What is a JWT?
A JSON Web Token (JWT) is a compact, URL-safe token that encodes a set of claims as a JSON object and optionally signs or encrypts them. It is the dominant token format in OAuth 2.0 / OIDC flows — both access tokens and ID tokens are typically JWTs.
Structure
A JWT is three Base64url-encoded parts separated by dots:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImFiYzEyMyJ9 ← Header
.eyJzdWIiOiJ1c2VyXzQyIiwiaXNzIjoiaHR0cHM6Ly9pZHAuZXhhbXBsZS5jb20iLCJhdWQiOiJteS1hcGkiLCJleHAiOjE3NDQwMDAwMDAsImlhdCI6MTc0Mzk5NjQwMH0 ← Payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature
1. Header
{
"alg": "RS256",
"typ": "JWT",
"kid": "abc123"
}
| Field | Description |
|---|---|
alg |
Signing algorithm (RS256, ES256, HS256, …) |
typ |
Token type — always JWT |
kid |
Key ID — tells the receiver which key to use for verification |
2. Payload (Claims)
{
"sub": "user_42",
"iss": "https://idp.example.com",
"aud": "my-api",
"exp": 1744000000,
"iat": 1743996400,
"email": "alice@example.com",
"roles": ["admin"]
}
| Claim | Meaning |
|---|---|
sub |
Subject — who the token is about |
iss |
Issuer — who created the token |
aud |
Audience — who the token is intended for |
exp |
Expiry timestamp (Unix epoch) |
iat |
Issued-at timestamp |
nbf |
Not-before timestamp (optional) |
jti |
Unique token ID, useful for revocation |
Everything else (email, roles, …) are custom claims added by the authorization server.
⚠️ The payload is only Base64url-encoded, not encrypted. Anyone can decode and read it. Never put secrets in a JWT payload unless the token is also encrypted (JWE).
3. Signature
RSASHA256(
base64url(header) + "." + base64url(payload),
privateKey
)
The signature guarantees that the token was issued by the party holding the private key and that neither the header nor the payload has been tampered with since.
Signing algorithms
| Algorithm | Type | Notes |
|---|---|---|
| RS256 | RSA + SHA-256 (asymmetric) | Most common in OAuth/OIDC. Private key signs, public key verifies. |
| ES256 | ECDSA + SHA-256 (asymmetric) | Shorter signatures, same security level as RS256. Preferred for mobile. |
| HS256 | HMAC + SHA-256 (symmetric) | Single shared secret — both parties must know it. Avoid for public APIs. |
| RS512 / ES512 | Same as above, SHA-512 | Higher security margin, larger tokens. |
For public APIs and OAuth flows, always prefer asymmetric algorithms (RS256 / ES256). The authorization server keeps its private key secret; any API can verify with the public key without ever seeing the private key.
How JWT validation should work
Accepting a JWT is not just decoding it. A receiver must perform all of these checks:
Step 1 — Structural check
Split on . and verify there are exactly three parts, each valid Base64url.
Step 2 — Fetch the public key via JWKS
The issuer publishes its public keys at a well-known endpoint:
GET https://idp.example.com/.well-known/jwks.json
A JWKS (JSON Web Key Set) response looks like:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "abc123",
"alg": "RS256",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2...",
"e": "AQAB"
}
]
}
| Field | Meaning |
|---|---|
kty |
Key type (RSA, EC, oct) |
use |
Intended use: sig (signature) or enc (encryption) |
kid |
Key ID — match this against the kid in the JWT header |
n, e |
RSA modulus and exponent (public key material) |
Match the kid from the JWT header to the correct key in the JWKS. If no kid is present, the receiver may try all keys — but this is fragile.
💡 Cache the JWKS response (respect
Cache-Control), but re-fetch when you encounter an unknownkid. Authorization servers rotate keys; hard-coding them is an operational risk.
Step 3 — Verify the signature
Reconstruct base64url(header) + "." + base64url(payload) and verify the signature using the fetched public key and the algorithm declared in the header.
Never trust the alg field from the token alone — always enforce the expected algorithm on your side. The classic attack is sending "alg": "none" to bypass signature verification.
Step 4 — Validate the claims
| Check | What to verify |
|---|---|
exp |
Token is not expired (exp > now) |
nbf |
Token is already valid (nbf <= now) |
iss |
Matches the expected issuer URL exactly |
aud |
Contains your API’s identifier |
alg (header) |
Matches the algorithm your service expects — reject anything else |
Step 5 — Check revocation (optional but recommended)
JWTs are stateless — once issued, a valid signature passes even if the user logged out or the token was revoked. Mitigations:
- Short expiry (
exp) — 5–15 minutes for access tokens is common. - Token introspection — call the authorization server’s
/introspectendpoint for sensitive operations. - Revocation list /
jtiblocklist — store revokedjtivalues in a fast store (Redis) and reject matches.
Full validation flow
Receive JWT
│
▼
Decode header → read kid + alg
│
▼
Fetch JWKS from issuer's .well-known endpoint (use cache)
│
▼
Find key where key.kid == jwt.kid
│
▼
Verify signature with public key
│
├── FAIL → reject (tampered or wrong issuer)
│
▼
Validate claims: exp, nbf, iss, aud, alg
│
├── FAIL → reject (expired, wrong audience, etc.)
│
▼
(Optional) Check jti against revocation list
│
▼
✅ Token accepted — trust the claims
Common mistakes
| Mistake | Risk |
|---|---|
| Not verifying the signature | Anyone can forge a token |
Trusting "alg": "none" |
Signature check is skipped entirely |
Not checking aud |
Your API accepts tokens meant for a different service |
Not checking exp |
Expired tokens remain valid forever |
| Hard-coding the public key | Breaks silently when the IdP rotates keys |
| Putting secrets in the payload | Payload is readable by anyone who holds the token |
JWT in the OAuth 2.0 / OIDC context
- Access token (OAuth 2.0): usually a JWT, used to call APIs. The API validates it locally using JWKS — no call to the authorization server needed on every request.
- ID token (OIDC): always a JWT, used by the client app to know who the user is. Must be validated the same way.
- Refresh token: typically not a JWT — it’s an opaque string stored server-side, exchanged for a new access token.