JSON Web Tokens are everywhere. Every OAuth flow, every API authentication layer, every modern SSO system uses them. But most developers treat them as opaque blobs — copy a token, paste it into a header, move on. This article breaks open the black box.
After reading this, try decoding and editing JWTs yourself in the JWT Playground.
What Is a JWT?#
A JWT is a compact, URL-safe string that carries claims (statements about a user or entity) in a way that can be cryptographically verified. It’s defined in RFC 7519.
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IktldmluIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cThree parts separated by dots: Header, Payload, and Signature.
┌────────────────┐ ┌────────────────────────┐ ┌──────────────────┐
│ HEADER │ . │ PAYLOAD │ . │ SIGNATURE │
│ │ │ │ │ │
│ Algorithm │ │ Claims about the user │ │ Proof that the │
│ Token type │ │ Issuer, expiry, etc. │ │ header+payload │
│ │ │ │ │ haven't been │
│ Base64URL │ │ Base64URL encoded │ │ tampered with │
│ encoded │ │ │ │ │
└────────────────┘ └────────────────────────┘ └──────────────────┘Anatomy: Decoding Each Part#
Header#
The header declares the algorithm and token type:
{
"alg": "HS256",
"typ": "JWT"
}Base64URL-encoded: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Common algorithms:
- HS256 — HMAC with SHA-256. Symmetric: same secret signs and verifies.
- RS256 — RSA with SHA-256. Asymmetric: private key signs, public key verifies.
- ES256 — ECDSA with P-256. Asymmetric, smaller keys than RSA.
Payload#
The payload contains claims — key-value pairs about the subject:
{
"sub": "1234567890",
"name": "Kevin",
"iat": 1516239022
}Base64URL-encoded: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IktldmluIiwiaWF0IjoxNTE2MjM5MDIyfQ
Important: the payload is encoded, not encrypted. Anyone who has the token can decode the payload. Never put secrets in the payload.
Signature#
The signature prevents tampering:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)If anyone modifies a single character in the header or payload, the signature won’t match. The verifier recomputes the signature with the known secret/public key and compares.
Standard Claims#
The JWT specification defines a set of registered claims — optional but recommended:
| Claim | Name | Purpose | Example |
|---|---|---|---|
iss | Issuer | Who created the token | "https://auth.example.com" |
sub | Subject | Who the token is about | "user:42" or "kevin@example.com" |
aud | Audience | Who should accept the token | "https://api.example.com" |
exp | Expiration | When the token expires (Unix timestamp) | 1711929600 |
iat | Issued At | When the token was created | 1711926000 |
nbf | Not Before | Token is not valid before this time | 1711926000 |
jti | JWT ID | Unique identifier for this specific token | "abc-123-def" |
exp is the most important one. Without it, a stolen token works forever.
The Token Lifecycle#
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Client │ │ Auth Server │ │ API Server │
│ (Browser) │ │ (IdP) │ │ (Resource) │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ 1. Login │ │
│ (credentials) │ │
│────────────────────▶│ │
│ │ │
│ 2. JWT returned │ │
│ (access_token) │ │
│◀────────────────────│ │
│ │ │
│ 3. API request │ │
│ Authorization: │ │
│ Bearer <JWT> │ │
│─────────────────────┼────────────────────▶│
│ │ │
│ │ 4. Verify JWT │
│ │ - Check sig │
│ │ - Check exp │
│ │ - Check aud │
│ │ │
│ 5. Response │ │
│◀─────────────────────┼────────────────────│
│ │ │Key insight: the API server never talks to the auth server during steps 3-5. The JWT is self-contained — the API server has the public key (or shared secret) and can verify the token independently. This is why JWTs scale so well: no session store, no auth server round-trip.
JWTs in OAuth 2.0 / OIDC#
In OAuth/OIDC flows, you encounter three types of tokens:
Access Token#
The most common JWT. Used to authorize API requests. Short-lived (minutes to hours).
{
"iss": "https://auth.example.com",
"sub": "user:42",
"aud": "https://api.example.com",
"scope": "read:data write:data",
"exp": 1711929600,
"iat": 1711926000
}ID Token#
An OIDC-specific token that identifies the user. Contains profile information. Used by the client application, never sent to APIs.
{
"iss": "https://auth.example.com",
"sub": "user:42",
"aud": "my-app-client-id",
"name": "Kevin Keller",
"email": "kevin@example.com",
"email_verified": true,
"exp": 1711929600
}Refresh Token#
Not a JWT in most implementations — it’s an opaque string stored server-side. Used to get new access tokens without re-authentication. Long-lived (days to weeks). Never send it to an API — only to the auth server’s token endpoint.
How Signing Actually Works#
Symmetric (HS256)#
Both parties share the same secret:
Sign: HMAC-SHA256(header.payload, "my-secret-key") → signature
Verify: HMAC-SHA256(header.payload, "my-secret-key") == signature?Simple, fast. But both the issuer and verifier need the same secret — which means the secret must be distributed securely. Fine for single-service architectures, problematic for microservices.
Asymmetric (RS256 / ES256)#
The issuer has a private key, verifiers have the public key:
Sign: RSA-Sign(header.payload, private_key) → signature
Verify: RSA-Verify(header.payload, signature, public_key) → true/falseThe public key can be published openly (often via a JWKS endpoint at /.well-known/jwks.json). Any service can verify tokens without having access to the signing key. This is why RS256/ES256 is the standard for production OAuth systems.
JWKS — JSON Web Key Set#
Identity providers publish their public keys at a well-known URL:
GET https://auth.example.com/.well-known/jwks.json{
"keys": [
{
"kty": "RSA",
"kid": "key-1",
"use": "sig",
"n": "0vx7agoebGc...",
"e": "AQAB"
}
]
}The kid (Key ID) in the JWT header tells the verifier which key to use. This allows key rotation without downtime — publish a new key, start signing with it, old tokens still verify with the old key until they expire.
Security Pitfalls#
1. The alg: none Attack#
If a server blindly trusts the alg field in the header, an attacker can set it to "none" (no signature), remove the signature, and the server accepts the token as valid.
Fix: Never accept alg: none. Hardcode the expected algorithm on the server side.
2. Weak HMAC Secrets#
If you use HS256 with a short or guessable secret ("password", "secret", "jwt-secret"), an attacker can brute-force the secret from a captured token.
Fix: Use secrets with at least 256 bits of entropy (32+ random bytes). Better yet, use RS256/ES256.
3. No Expiration#
A token without exp is valid forever. If leaked, the attacker has permanent access.
Fix: Always set exp. Access tokens: 15-60 minutes. Refresh tokens: hours to days.
4. Token Storage#
| Storage | XSS Vulnerable? | CSRF Vulnerable? | Recommendation |
|---|---|---|---|
localStorage | Yes | No | Avoid for sensitive tokens |
sessionStorage | Yes | No | Slightly better (cleared on tab close) |
httpOnly cookie | No | Yes (mitigated with SameSite) | Preferred |
| Memory (JS variable) | Partially | No | Best for short-lived tokens |
The safest pattern: store the access token in memory (JS variable) and the refresh token in an httpOnly, Secure, SameSite=Strict cookie.
5. Accepting Tokens for the Wrong Audience#
If your API accepts any valid JWT without checking the aud claim, a token issued for Service A can be used to access Service B.
Fix: Always validate aud matches your service identifier.
6. Not Validating the Issuer#
Similarly, always check iss. A token signed by a different auth server (even with a valid signature) should be rejected.
JWT vs Session Tokens#
| JWT | Session Token | |
|---|---|---|
| State | Stateless (self-contained) | Stateful (server stores session) |
| Revocation | Hard (must wait for expiry or use blocklist) | Easy (delete session from store) |
| Scalability | Excellent (no shared session store) | Requires shared store (Redis, DB) |
| Size | Larger (~800 bytes+) | Small (~32 bytes, just an ID) |
| Inspection | Payload is readable | Opaque to the client |
JWTs trade revocability for scalability. If you need instant revocation (e.g., “ban this user right now”), you need either short-lived tokens + frequent refresh, or a token blocklist — which partially defeats the stateless benefit.
Try It Yourself#
Decode, edit, and verify JWTs right in your browser:
JWT Playground — paste any JWT, see the decoded header and payload, edit claims, and verify signatures. Everything runs locally.
