JWT Tokens Explained: Structure, Security, and Best Practices
What Is a JWT?
A JSON Web Token (JWT, pronounced "jot") is a compact, URL-safe token format used to securely transmit claims between two parties. JWTs are the backbone of modern authentication: when you log into an application and subsequent requests are authenticated without hitting the database, a JWT is almost certainly involved.
Unlike session-based authentication where the server stores session data, JWTs are stateless. All the information needed to verify the user is contained within the token itself. The server signs the token with a secret key, and any service that knows the key (or has the public key) can verify its authenticity without network calls.
// A JWT looks like this (three Base64-encoded parts separated by dots):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
// Three parts:
// 1. Header → algorithm and token type
// 2. Payload → claims (user data, expiration, etc.)
// 3. Signature → cryptographic proof of authenticityTo decode and inspect any JWT instantly, paste it into our JWT Decoder. It splits the token into its three parts, decodes the header and payload, and shows the signature -- all in your browser without sending the token to any server.
The Three Parts of a JWT
1. Header
The header is a JSON object that specifies the signing algorithm and the token type. It is Base64Url-encoded to form the first part of the JWT.
{
"alg": "HS256", // signing algorithm
"typ": "JWT" // token type
}
// Base64Url-encoded: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
// Common algorithms:
// HS256 — HMAC with SHA-256 (symmetric, shared secret)
// RS256 — RSA with SHA-256 (asymmetric, public/private key)
// ES256 — ECDSA with P-256 and SHA-256 (asymmetric, smaller keys)
// EdDSA — Edwards-curve DSA (asymmetric, modern, fast)2. Payload (Claims)
The payload contains claims -- statements about the user and metadata. Claims are categorized into three types: registered (standard), public (community-defined), and private (application-specific).
{
// Registered claims (standardized by RFC 7519)
"iss": "https://auth.example.com", // issuer
"sub": "user-12345", // subject (user ID)
"aud": "https://api.example.com", // audience
"exp": 1709856000, // expiration time (Unix timestamp)
"nbf": 1709852400, // not before (token not valid before this)
"iat": 1709852400, // issued at
"jti": "a1b2c3d4-e5f6-7890", // JWT ID (unique identifier)
// Private claims (application-specific)
"role": "admin",
"permissions": ["read", "write", "delete"],
"email": "[email protected]"
}| Claim | Full Name | Purpose |
|---|---|---|
| iss | Issuer | Who created and signed the token |
| sub | Subject | Who the token is about (usually user ID) |
| aud | Audience | Who the token is intended for |
| exp | Expiration | When the token expires (Unix timestamp) |
| nbf | Not Before | Token is invalid before this time |
| iat | Issued At | When the token was created |
| jti | JWT ID | Unique identifier (prevents replay attacks) |
3. Signature
The signature ensures the token has not been tampered with. It is created by encoding the header and payload, then signing them with the algorithm specified in the header.
// HS256 signature formula:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
// RS256 signature formula:
RSASHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
privateKey
)
// The signature is what makes JWTs tamper-proof:
// If anyone changes the header or payload, the signature won't match
// If anyone tries to re-sign, they don't have the secret/private keyThe header and payload are Base64Url-encoded, not encrypted. Anyone can decode them -- the signature only proves authenticity, not confidentiality. To understand Base64 encoding in depth, read our Base64 Encoding Explained guide, or use our Base64 Encoder/Decoder tool.
Signing Algorithms: HS256 vs RS256 vs ES256
| Algorithm | Type | Key | Best For |
|---|---|---|---|
| HS256 | Symmetric | Shared secret (256 bits) | Monoliths, single-server apps |
| RS256 | Asymmetric | RSA key pair (2048+ bits) | Microservices, third-party verification |
| ES256 | Asymmetric | ECDSA P-256 key pair | Mobile apps, performance-sensitive |
| EdDSA | Asymmetric | Ed25519 key pair | Modern systems, fastest asymmetric |
// HS256: Same secret for signing AND verification
// ✓ Simple setup
// ✓ Fast
// ✗ Secret must be shared with every verifier
// ✗ If one service is compromised, all are
// RS256: Private key signs, public key verifies
// ✓ Only auth server has private key
// ✓ Any service can verify with public key
// ✓ Public key can be freely distributed (JWKS endpoint)
// ✗ Slower than HS256
// ✗ Larger key size
// ES256: Like RS256 but with smaller keys and faster signing
// ✓ 256-bit key ≈ 3072-bit RSA security
// ✓ Smaller tokens
// ✓ Faster than RS256
// ✗ Slightly less library support than RS256HS256 uses HMAC-SHA256 under the hood. To understand how HMAC works, see the HMAC section in our Hash Functions Explained guide. For generating secure secrets, use our Password Generator with maximum length and all character types enabled.
Creating and Verifying JWTs
Node.js with jsonwebtoken
// npm install jsonwebtoken
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET; // at least 256 bits (32+ chars)
// Create a token
function createToken(user) {
return jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role,
},
SECRET,
{
expiresIn: '15m', // 15 minutes
issuer: 'https://api.example.com',
audience: 'https://app.example.com',
}
);
}
// Verify a token
function verifyToken(token) {
try {
const decoded = jwt.verify(token, SECRET, {
issuer: 'https://api.example.com',
audience: 'https://app.example.com',
algorithms: ['HS256'], // ALWAYS specify allowed algorithms
});
return { valid: true, payload: decoded };
} catch (error) {
return { valid: false, error: error.message };
}
}
// Express middleware
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = authHeader.split(' ')[1];
const result = verifyToken(token);
if (!result.valid) {
return res.status(401).json({ error: 'Invalid token' });
}
req.user = result.payload;
next();
}Python with PyJWT
# pip install pyjwt
import jwt
from datetime import datetime, timedelta, timezone
SECRET = "your-256-bit-secret"
# Create a token
def create_token(user_id: str, role: str) -> str:
payload = {
"sub": user_id,
"role": role,
"iat": datetime.now(timezone.utc),
"exp": datetime.now(timezone.utc) + timedelta(minutes=15),
"iss": "https://api.example.com",
}
return jwt.encode(payload, SECRET, algorithm="HS256")
# Verify a token
def verify_token(token: str) -> dict:
try:
return jwt.decode(
token,
SECRET,
algorithms=["HS256"], # ALWAYS specify algorithms
issuer="https://api.example.com",
)
except jwt.ExpiredSignatureError:
raise ValueError("Token has expired")
except jwt.InvalidTokenError as e:
raise ValueError(f"Invalid token: {e}")
# FastAPI dependency
from fastapi import Depends, HTTPException, Header
async def get_current_user(authorization: str = Header(...)):
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Invalid auth header")
token = authorization.split(" ")[1]
try:
return verify_token(token)
except ValueError as e:
raise HTTPException(status_code=401, detail=str(e))RS256 with Public/Private Keys
// Generate RSA key pair:
// openssl genrsa -out private.pem 2048
// openssl rsa -in private.pem -pubout -out public.pem
const fs = require('fs');
const jwt = require('jsonwebtoken');
const PRIVATE_KEY = fs.readFileSync('private.pem');
const PUBLIC_KEY = fs.readFileSync('public.pem');
// Auth server: sign with PRIVATE key
const token = jwt.sign({ sub: 'user-123' }, PRIVATE_KEY, {
algorithm: 'RS256',
expiresIn: '15m',
});
// Any service: verify with PUBLIC key (no secret needed)
const decoded = jwt.verify(token, PUBLIC_KEY, {
algorithms: ['RS256'],
});
// JWKS endpoint (JSON Web Key Set)
// Most auth providers expose their public keys at:
// https://auth.example.com/.well-known/jwks.json
// Services fetch the public key from this endpoint to verify tokensThe JWT Authentication Flow
// 1. User logs in
POST /auth/login
Body: { "email": "[email protected]", "password": "..." }
// 2. Server validates credentials, creates tokens
Response: {
"accessToken": "eyJhbGci...", // short-lived (15 min)
"refreshToken": "eyJhbGci...", // long-lived (7-30 days)
"expiresIn": 900 // seconds until access token expires
}
// refreshToken is also set as HttpOnly cookie
// 3. Client stores access token in memory (NOT localStorage)
let accessToken = response.accessToken;
// 4. Client sends access token with every API request
GET /api/users
Authorization: Bearer eyJhbGci...
// 5. When access token expires (401 response):
POST /auth/refresh
Cookie: refreshToken=eyJhbGci... // sent automatically
// 6. Server validates refresh token, issues new access token
Response: {
"accessToken": "eyJhbGci...", // new token
"expiresIn": 900
}
// 7. If refresh token is expired or invalid: redirect to loginSecurity Best Practices
- Always specify allowed algorithms -- The "none" algorithm attack exploits libraries that accept the algorithm from the token header. Always pass
algorithms: ['HS256'](or your chosen algorithm) when verifying. - Use short expiration for access tokens -- Access tokens should expire in 5-15 minutes. If a token is stolen, the window of vulnerability is limited.
- Store tokens securely -- Access tokens in memory (JavaScript variable). Refresh tokens in HttpOnly, Secure, SameSite=Strict cookies. Never in localStorage or sessionStorage.
- Validate all registered claims -- Check
exp(expiration),iss(issuer), andaud(audience) on every verification. Do not trust a token just because the signature is valid. - Implement token rotation for refresh tokens -- Issue a new refresh token every time one is used. If an old refresh token is replayed, invalidate all tokens for that user (indicating theft).
- Never put sensitive data in the payload -- JWTs are encoded, not encrypted. Anyone can decode the payload. Do not include passwords, SSNs, or API keys.
- Use strong secrets -- For HS256, use at least 256 bits (32 characters) of cryptographically random data. A weak secret can be brute-forced.
- Consider token size -- Every claim adds to the token size, and tokens are sent with every request. Keep payloads minimal. If you need extensive permissions data, fetch it from the server after verifying the token.
Common JWT Attacks and Defenses
| Attack | How It Works | Defense |
|---|---|---|
| Algorithm: none | Attacker sets alg to "none", removes signature | Always specify allowed algorithms in verify() |
| Algorithm confusion | Switches RS256 to HS256, uses public key as HMAC secret | Specify algorithms; use separate verification paths for symmetric/asymmetric |
| Token theft (XSS) | JavaScript reads token from localStorage | Store tokens in memory or HttpOnly cookies, implement CSP headers |
| Brute-force secret | Weak HMAC secret is guessable | Use 256+ bit cryptographically random secrets |
| Replay attack | Stolen token is reused before expiration | Short expiration (5-15 min), jti claim for critical operations |
| Refresh token theft | Long-lived refresh token is stolen | Token rotation, reuse detection, HttpOnly cookies |
The "alg: none" Attack in Detail
// VULNERABLE code — DO NOT do this:
const decoded = jwt.verify(token, secret);
// This reads the algorithm from the token header!
// If the attacker sets "alg": "none", no verification happens
// SAFE code — always specify algorithms:
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'] // only accept HS256
});
// If the token has "alg": "none" or "alg": "RS256",
// verification will fail immediatelyToken Revocation Strategies
One of the biggest limitations of JWTs is that they cannot be revoked once issued (they are stateless). If a user logs out or their account is compromised, the access token remains valid until it expires. Here are strategies to address this.
| Strategy | How It Works | Trade-off |
|---|---|---|
| Short expiration | Access tokens expire in 5-15 minutes | Requires refresh flow; small revocation delay |
| Token blocklist | Store revoked token JTIs in Redis | Adds a database check (partially defeats stateless benefit) |
| Token version | Store version in user record; token has version claim | Requires DB lookup; changing version invalidates all tokens |
| Refresh token rotation | New refresh token per use; old one invalidated | Reuse detection catches theft; slightly more complex |
// Token blocklist with Redis (Node.js)
const Redis = require('ioredis');
const redis = new Redis();
// On logout: add token to blocklist
async function revokeToken(token) {
const decoded = jwt.decode(token);
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await redis.setex(`blocklist:${decoded.jti}`, ttl, '1');
}
}
// On every request: check blocklist
async function isTokenRevoked(token) {
const decoded = jwt.decode(token);
const revoked = await redis.get(`blocklist:${decoded.jti}`);
return !!revoked;
}
// Middleware
async function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Missing token' });
if (await isTokenRevoked(token)) {
return res.status(401).json({ error: 'Token has been revoked' });
}
try {
req.user = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
}JWT vs Session Cookies: When to Use Each
| Factor | JWT | Session Cookie |
|---|---|---|
| State | Stateless (data in token) | Stateful (data on server) |
| Scalability | No shared state needed | Requires shared session store |
| Revocation | Difficult (needs blocklist) | Easy (delete from store) |
| Cross-domain | Works via Authorization header | Limited by cookie domain rules |
| Mobile apps | Native support | Requires cookie handling |
| Best for | APIs, microservices, mobile | Traditional web apps, SSR |
For a deeper comparison with code examples and architectural guidance, read our dedicated JWT vs Session Cookies article. For understanding the HTTP layer that carries these tokens, see our HTTP Status Codes Guide.
When NOT to Use JWTs
JWTs are not always the right choice. Here are situations where session-based authentication or other approaches work better.
- When immediate revocation is critical -- Banking, healthcare, and other high-security applications need the ability to instantly invalidate sessions. Session cookies do this naturally; JWTs require a blocklist.
- When you only have one server -- The stateless benefit of JWTs does not matter if you have a single server. Session cookies are simpler to implement and more secure by default.
- When token size matters -- JWTs can be 500+ bytes. If they are sent in cookies, you may hit the 4KB cookie size limit. Session IDs are typically 32-64 bytes.
- When you need to store lots of user data -- Putting extensive user data in the JWT payload makes it large. Store the data server-side and use the token only for identity.
Frequently Asked Questions
Where should I store JWT tokens on the client?
Store access tokens in memory (a JavaScript variable) and refresh tokens in HttpOnly, Secure, SameSite cookies. Never store tokens in localStorage or sessionStorage -- they are accessible to any JavaScript on the page, making them vulnerable to XSS attacks. HttpOnly cookies cannot be read by JavaScript, protecting against token theft. If you must use localStorage (for example, in mobile webviews), keep access tokens short-lived (5-15 minutes) and implement refresh token rotation.
What is the difference between HS256 and RS256?
HS256 (HMAC-SHA256) uses a single shared secret for both signing and verification. It is simpler and faster, but every service that verifies tokens needs the same secret. RS256 (RSA-SHA256) uses a key pair: a private key for signing and a public key for verification. Only the auth server needs the private key; any service can verify tokens using the public key. Use HS256 for monolithic applications. Use RS256 for microservices where multiple services verify tokens independently.
How do I handle JWT token expiration and refresh?
Use a two-token strategy: a short-lived access token (5-15 minutes) and a long-lived refresh token (7-30 days). When the access token expires, send the refresh token to a dedicated endpoint to get a new access token without requiring re-login. Implement token rotation -- issue a new refresh token each time one is used, and invalidate the old one. If a refresh token is used twice (indicating theft), invalidate all tokens for that user.
Decode and Inspect JWT Tokens
Paste any JWT into our free decoder to see the header, payload, and signature. Verify the structure, check claims, and debug authentication issues -- all in your browser.
Open JWT DecoderRelated Articles
JWT Tokens Deep Dive
Extended reference covering JWKS endpoints, token introspection, and OAuth2 flows.
JWT vs Session Cookies
Compare stateful and stateless authentication methods for web and mobile.
Hash Functions Explained
MD5, SHA-256, bcrypt -- how the HMAC behind HS256 actually works.
Base64 Encoding Explained
How JWT header and payload are encoded and why Base64Url differs from Base64.