JWT Tokens Explained: How Authentication Works in Modern Web Apps
A thorough explanation of JSON Web Tokens — how they work, the structure of header/payload/signature, access vs refresh tokens, common security pitfalls, and implementation patterns.
What is a JWT?
A JSON Web Token (JWT, pronounced "jot") is a compact, URL-safe token format used for securely transmitting information between parties. JWTs are the backbone of authentication in most modern web applications and APIs.
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
It consists of three parts separated by dots: Header.Payload.Signature
JWT Structure
Header
The header typically contains two fields:
{
"alg": "HS256",
"typ": "JWT"
}
alg— The signing algorithm (HS256, RS256, ES256, etc.)typ— The token type (always "JWT")
This JSON is Base64URL-encoded to form the first part of the JWT.
Payload (Claims)
The payload contains claims — statements about the user and additional metadata:
{
"sub": "1234567890",
"name": "John Doe",
"email": "john@example.com",
"role": "admin",
"iat": 1516239022,
"exp": 1516242622
}
Registered claims (standardized):
iss— Issuersub— Subject (usually user ID)aud— Audienceexp— Expiration time (Unix timestamp)nbf— Not beforeiat— Issued atjti— JWT ID (unique identifier for the token)
Custom claims can be anything you need: role, permissions, email, etc.
Signature
The signature is created by combining the encoded header, encoded payload, and a secret:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
The signature ensures that:
- The token hasn't been tampered with
- The token was issued by a trusted party
How JWT Authentication Works
The Flow
- User logs in with credentials (email/password)
- Server validates credentials against the database
- Server creates a JWT with user info and signs it with a secret key
- Server sends the JWT back to the client
- Client stores the JWT (usually in memory or httpOnly cookie)
- Client sends the JWT with every subsequent request (Authorization header)
- Server verifies the JWT signature and extracts user info
- Server processes the request based on the user's identity and permissions
Client Server
| |
|-- POST /login {email, pass} ->|
| |-- Validate credentials
| |-- Create JWT
|<- 200 {token: "eyJ..."} -----|
| |
|-- GET /api/data ------------->|
| Authorization: Bearer eyJ...|
| |-- Verify JWT signature
| |-- Extract user from payload
|<- 200 {data: [...]} ---------|
Access Tokens vs Refresh Tokens
A common pattern uses two types of tokens:
Access Token:
- Short-lived (5-15 minutes)
- Contains user claims
- Sent with every API request
- If compromised, limited damage window
Refresh Token:
- Long-lived (days to weeks)
- Stored securely (httpOnly cookie)
- Used only to get new access tokens
- Can be revoked server-side
// Token refresh flow
async function fetchWithAuth(url, options = {}) {
let response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
});
if (response.status === 401) {
// Access token expired, try refresh
const refreshResponse = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include", // sends httpOnly cookie
});
if (refreshResponse.ok) {
const { accessToken: newToken } = await refreshResponse.json();
accessToken = newToken;
// Retry original request
response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${newToken}`,
},
});
}
}
return response;
}
Security Best Practices
1. Use Strong Signing Algorithms
- HS256 — HMAC with SHA-256. Symmetric (same key for signing and verification). Good for simple setups.
- RS256 — RSA with SHA-256. Asymmetric (private key signs, public key verifies). Better for distributed systems.
- ES256 — ECDSA with SHA-256. Asymmetric, smaller keys than RSA. Best performance.
Never use alg: "none" — This disables signature verification entirely.
2. Set Short Expiration Times
const token = jwt.sign(payload, secret, {
expiresIn: "15m", // 15 minutes for access tokens
});
3. Don't Store Sensitive Data in the Payload
The payload is Base64-encoded, NOT encrypted. Anyone can decode it:
// Anyone can read this!
atob("eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0");
// {"sub":"1234567890","name":"John Doe"}
Never put passwords, credit card numbers, or other secrets in the JWT payload.
4. Store Tokens Securely
- Best: httpOnly, Secure, SameSite cookies (immune to XSS)
- Acceptable: In-memory variable (lost on page refresh)
- Avoid: localStorage or sessionStorage (vulnerable to XSS)
5. Validate All Claims
Always verify:
- Signature is valid
- Token hasn't expired (
exp) - Issuer is correct (
iss) - Audience is correct (
aud)
6. Implement Token Revocation
JWTs are stateless by design — you can't "invalidate" a JWT without additional infrastructure. Common approaches:
- Short expiration + refresh tokens — Most common
- Token blacklist — Store revoked token IDs in Redis
- Token versioning — Increment a version number in the database; reject tokens with old versions
Common JWT Pitfalls
-
Using JWT for sessions — JWTs are great for API authentication but add complexity for traditional web sessions. Consider server-side sessions for simple web apps.
-
Storing too much data — Every claim increases token size, and the token is sent with every request. Keep payloads small.
-
Not validating the algorithm — Always specify the expected algorithm when verifying. Don't let the token's header dictate the algorithm.
-
Confusing encoding with encryption — Base64 is NOT encryption. JWTs are signed, not encrypted (unless you use JWE).
Decode and inspect your JWTs with our JWT Decoder tool.