TL;DR
JWT (JSON Web Token) is a compact, self-contained token format for transmitting authenticated claims between parties. A JWT has three base64url-encoded parts: header.payload.signature. Use HS256 for single-service auth and RS256 for multi-service architectures. Always set exp, verify the algorithm explicitly, and never store JWTs in localStorage. Use short-lived access tokens (15 min) combined with refresh tokens for the best balance of security and UX. Decode and inspect your tokens instantly with our online JWT decoder.
JWT Structure — header.payload.signature
A JSON Web Token (JWT) is defined by RFC 7519 as a compact URL-safe means of representing claims to be transferred between two parties. Every JWT has exactly three parts, separated by dots (.), each individually base64url-encoded:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyXzEyMyIsImlzcyI6ImFwaS5leGFtcGxlLmNvbSIsImV4cCI6MTcwOTQ3MzYwMCwiaWF0IjoxNzA5NDY2NDAwLCJqdGkiOiJ1dWlkLWFiYy0xMjMifQ
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
# Part 1 — Header (decoded):
{
"alg": "HS256", // signing algorithm
"typ": "JWT" // token type
}
# Part 2 — Payload (decoded):
{
"sub": "user_123", // subject (user ID)
"iss": "api.example.com", // issuer
"exp": 1709473600, // expiry (Unix timestamp)
"iat": 1709466400, // issued at
"jti": "uuid-abc-123", // JWT ID (for revocation)
"role": "admin", // custom claim
"email": "alice@example.com" // custom claim
}
# Part 3 — Signature:
HMACSHA256(
base64url(header) + "." + base64url(payload),
secret
)The header specifies the algorithm (alg) and token type (typ). The payload contains claims — the standard registered ones are:
| Claim | Full Name | Purpose | Required? |
|---|---|---|---|
iss | Issuer | Who issued the token (your domain or service name) | Recommended |
sub | Subject | Who the token is about (user ID) | Recommended |
exp | Expiration Time | Token is invalid after this Unix timestamp | Critical |
iat | Issued At | When the token was created | Recommended |
jti | JWT ID | Unique identifier for revocation/replay prevention | Optional but useful |
Important: JWT payloads are base64url-encoded, not encrypted. Anyone who has the token can read the payload — do not store sensitive data like passwords, credit card numbers, or secrets in JWT claims. The signature only proves the token was not tampered with; it does not hide the contents.
Creating JWTs in Node.js with jsonwebtoken
The jsonwebtoken package is the standard Node.js library for creating and verifying JWTs.
npm install jsonwebtoken
npm install --save-dev @types/jsonwebtokenimport jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET!; // at least 32 chars in production
// ─── Sign (create) a JWT ─────────────────────────────────────────────────────
const token = jwt.sign(
{
sub: 'user_123',
email: 'alice@example.com',
role: 'admin',
},
SECRET,
{
expiresIn: '15m', // 15 minutes for access tokens
algorithm: 'HS256',
issuer: 'api.example.com',
audience: 'app.example.com',
jwtid: crypto.randomUUID(), // unique jti for revocation
}
);
// ─── Verify a JWT ────────────────────────────────────────────────────────────
try {
const decoded = jwt.verify(token, SECRET, {
algorithms: ['HS256'], // ALWAYS specify — prevents alg:none attack
issuer: 'api.example.com',
audience: 'app.example.com',
}) as jwt.JwtPayload;
console.log(decoded.sub); // 'user_123'
console.log(decoded.role); // 'admin'
console.log(decoded.exp); // expiry timestamp
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
console.error('Token expired at', err.expiredAt);
} else if (err instanceof jwt.JsonWebTokenError) {
console.error('Invalid token:', err.message);
} else if (err instanceof jwt.NotBeforeError) {
console.error('Token not yet active');
}
}
// ─── Decode without verifying (for debugging only) ───────────────────────────
const payload = jwt.decode(token);
console.log(payload); // DO NOT use this for auth — signature is not verifiedThe expiresIn option accepts human-readable strings ('15m', '7d', '1h') or numbers in seconds. The algorithms array in verify() is critical — without it, some older library versions accept the alg:none attack.
JWT with Express Middleware
A well-structured Express JWT middleware extracts the Bearer token, verifies it, attaches the decoded payload to the request, and handles errors consistently.
// middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export interface AuthRequest extends Request {
user?: jwt.JwtPayload;
}
export function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or malformed Authorization header' });
}
const token = authHeader.slice(7); // Remove "Bearer " prefix
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ['HS256'],
issuer: 'api.example.com',
}) as jwt.JwtPayload;
req.user = decoded;
next();
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token', code: 'INVALID_TOKEN' });
}
}
// Optional: role-based authorization middleware
export function authorize(...roles: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
if (roles.length && !roles.includes(req.user.role as string)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Apply middleware to protected routes
router.get('/profile', authenticate, (req: AuthRequest, res) => {
res.json({ user: req.user });
});
router.delete('/users/:id', authenticate, authorize('admin'), (req, res) => {
res.json({ deleted: req.params.id });
});Refresh Token Endpoints in Express
// POST /auth/login — issue both tokens
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await validateCredentials(email, password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const accessToken = jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: '15m', algorithm: 'HS256' }
);
const refreshToken = jwt.sign(
{ sub: user.id, type: 'refresh' },
process.env.REFRESH_SECRET!,
{ expiresIn: '7d', algorithm: 'HS256' }
);
// Store refresh token in HttpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken });
});
// POST /auth/refresh — issue new access token from refresh token
app.post('/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
try {
const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET!, {
algorithms: ['HS256'],
}) as jwt.JwtPayload;
const newAccessToken = jwt.sign(
{ sub: decoded.sub },
process.env.JWT_SECRET!,
{ expiresIn: '15m', algorithm: 'HS256' }
);
res.json({ accessToken: newAccessToken });
} catch {
res.clearCookie('refreshToken');
res.status(401).json({ error: 'Invalid or expired refresh token' });
}
});Next.js App Router JWT Authentication
Next.js 13+ App Router provides multiple integration points for JWT: server actions, route handlers, and middleware. The recommended approach stores JWTs in HttpOnly cookies and validates them in middleware.ts.
middleware.ts — Protecting Routes at the Edge
// middleware.ts (project root)
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose'; // 'jose' is edge-compatible; jsonwebtoken is not
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function middleware(request: NextRequest) {
const token = request.cookies.get('accessToken')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
await jwtVerify(token, SECRET, {
algorithms: ['HS256'],
issuer: 'api.example.com',
});
return NextResponse.next();
} catch {
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete('accessToken');
return response;
}
}
export const config = {
matcher: ['/dashboard/:path*', '/profile/:path*', '/admin/:path*'],
};Server Action Login with Cookies
// app/actions/auth.ts
'use server';
import { cookies } from 'next/headers';
import { SignJWT, jwtVerify } from 'jose';
import { redirect } from 'next/navigation';
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function loginAction(formData: FormData) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const user = await validateUser(email, password);
if (!user) throw new Error('Invalid credentials');
const token = await new SignJWT({ sub: user.id, email: user.email, role: user.role })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer('api.example.com')
.setExpirationTime('15m')
.sign(SECRET);
cookies().set('accessToken', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60,
path: '/',
});
redirect('/dashboard');
}
export async function getCurrentUser() {
const token = cookies().get('accessToken')?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, SECRET, { algorithms: ['HS256'] });
return payload;
} catch {
return null;
}
}Python PyJWT and FastAPI JWT Integration
pip install PyJWT cryptographyimport jwt
from datetime import datetime, timedelta, timezone
SECRET_KEY = "your-super-secret-key-at-least-32-chars"
ALGORITHM = "HS256"
def create_access_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": "api.example.com",
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str) -> dict:
try:
return jwt.decode(
token,
SECRET_KEY,
algorithms=[ALGORITHM], # always specify algorithms
options={"require": ["exp", "sub", "iss"]},
issuer="api.example.com",
)
except jwt.ExpiredSignatureError:
raise ValueError("Token has expired")
except jwt.InvalidTokenError as e:
raise ValueError(f"Invalid token: {e}")FastAPI Dependency Injection for JWT Auth
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt
app = FastAPI()
security = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
token = credentials.credentials
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM], issuer="api.example.com")
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token expired",
headers={"WWW-Authenticate": "Bearer"},
)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
@app.get("/profile")
def get_profile(current_user: dict = Depends(get_current_user)):
return {"user_id": current_user["sub"], "role": current_user["role"]}
def require_admin(current_user: dict = Depends(get_current_user)) -> dict:
if current_user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return current_user
@app.delete("/users/{user_id}")
def delete_user(user_id: str, _: dict = Depends(require_admin)):
return {"deleted": user_id}JWT Refresh Token Pattern — Access + Refresh Token Architecture
Short-lived access tokens combined with long-lived refresh tokens provide the best balance of security and user experience.
| Token Type | Lifetime | Storage | Purpose |
|---|---|---|---|
| Access Token | 15 minutes | Memory / HttpOnly cookie | Sent with every API request |
| Refresh Token | 7 days | HttpOnly Secure cookie only | Obtains new access tokens |
// Redis-backed refresh token rotation
import { createClient } from 'redis';
import jwt from 'jsonwebtoken';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
async function issueTokens(userId: string) {
const accessToken = jwt.sign({ sub: userId }, process.env.JWT_SECRET!, {
expiresIn: '15m', algorithm: 'HS256',
});
const refreshTokenId = crypto.randomUUID();
const refreshToken = jwt.sign(
{ sub: userId, jti: refreshTokenId, type: 'refresh' },
process.env.REFRESH_SECRET!,
{ expiresIn: '7d', algorithm: 'HS256' }
);
await redis.setEx(`refresh:${userId}:${refreshTokenId}`, 7 * 24 * 60 * 60, 'valid');
return { accessToken, refreshToken };
}
async function rotateRefreshToken(refreshToken: string) {
const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET!, {
algorithms: ['HS256'],
}) as jwt.JwtPayload;
const key = `refresh:${decoded.sub}:${decoded.jti}`;
const exists = await redis.get(key);
if (!exists) {
// Reuse detected — possible theft; invalidate all sessions
await redis.del(await redis.keys(`refresh:${decoded.sub}:*`));
throw new Error('Refresh token reuse detected');
}
await redis.del(key);
return issueTokens(decoded.sub as string);
}RS256 Asymmetric JWT — Private Key Signing, Public Key Verification
RS256 uses an RSA key pair: the private key signs tokens (kept secret on the auth server only), and the public key verifies them (can be distributed freely to all services).
# Generate RSA key pair (4096-bit recommended)
openssl genrsa -out private.pem 4096
openssl rsa -in private.pem -pubout -out public.pemimport fs from 'fs';
import jwt from 'jsonwebtoken';
const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');
// Auth server: sign with private key
const token = jwt.sign(
{ sub: 'user_123', role: 'editor' },
privateKey,
{
algorithm: 'RS256',
expiresIn: '15m',
keyid: 'key-2024-01',
issuer: 'auth.example.com',
}
);
// Any service: verify with public key
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // NEVER allow HS256 here — algorithm confusion attack!
issuer: 'auth.example.com',
});
// JWKS endpoint — serve public keys for auto-discovery
import { createPublicKey } from 'crypto';
import { exportJWK } from 'jose';
app.get('/.well-known/jwks.json', async (req, res) => {
const publicKeyObj = createPublicKey(publicKey);
const jwk = await exportJWK(publicKeyObj);
res.json({ keys: [{ ...jwk, kid: 'key-2024-01', use: 'sig', alg: 'RS256' }] });
});
// Consuming service: auto-fetch key from JWKS
import jwksClient from 'jwks-rsa';
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true,
cacheMaxAge: 600000,
});
function getPublicKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) {
client.getSigningKey(header.kid, (err, key) => {
callback(err, key?.getPublicKey());
});
}
jwt.verify(token, getPublicKey, { algorithms: ['RS256'] }, (err, decoded) => {
if (err) console.error('Verification failed:', err.message);
else console.log('Decoded:', decoded);
});Common JWT Vulnerabilities and How to Prevent Them
1. Algorithm Confusion / alg:none Attack
// VULNERABLE: not specifying algorithms allows alg:none and RS256->HS256 confusion
jwt.verify(token, secret);
// RS256->HS256 confusion: attacker changes header alg to HS256,
// signs with the PUBLIC key (which is known). If the verifier uses
// the public key as an HS256 secret, verification passes!
// PREVENTION: always pass the algorithms array
jwt.verify(token, publicKey, { algorithms: ['RS256'] }); // RS256 only
jwt.verify(token, secret, { algorithms: ['HS256'] }); // HS256 only2. Weak Secret Brute Force
# HS256 tokens can be brute-forced offline — attacker needs only the token
hashcat -a 0 -m 16500 token.txt wordlist.txt
# Bad: short or dictionary-based secrets
JWT_SECRET=secret # cracked in seconds
JWT_SECRET=myapp2024 # cracked in minutes
# Good: cryptographically random 256-bit secret
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# → e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b8553. Missing Expiry and Sensitive Data in Payload
// VULNERABLE: no expiry — stolen tokens are valid forever
const token = jwt.sign({ sub: userId }, secret);
// VULNERABLE: sensitive data in payload (base64url is NOT encryption!)
const token = jwt.sign({ password: 'abc123', creditCard: '4111...' }, secret);
// CORRECT: set expiry, store only minimal non-sensitive claims
const token = jwt.sign(
{ sub: user.id, role: user.role, scope: ['read', 'write'] },
secret,
{ expiresIn: '15m' }
);
// For truly sensitive payloads: use JWE (JSON Web Encryption)
// JWE encrypts the payload with AES-256-GCM in addition to signing4. Token Fixation and Missing Audience Validation
// VULNERABLE: token issued for one service accepted by another
// (token fixation — a token for service A works on service B)
// PREVENTION: always set and validate the audience claim
const token = jwt.sign({ sub: userId }, secret, {
audience: 'api.service-a.com', // set intended audience
issuer: 'auth.example.com',
});
jwt.verify(token, secret, {
algorithms: ['HS256'],
audience: 'api.service-a.com', // reject tokens for other services
issuer: 'auth.example.com',
});JWT vs Session-Based Authentication — When to Use Each
| Aspect | JWT (Stateless) | Sessions (Stateful) |
|---|---|---|
| Server storage | None — token is self-contained | Requires DB/Redis per session |
| Horizontal scaling | Easy — any server can verify | Requires sticky sessions or shared store |
| Instant logout/revocation | Hard — requires blocklist in Redis | Easy — delete session record |
| Microservices | Excellent — no shared state needed | Difficult — requires shared session store |
| Token/cookie size | Large (hundreds of bytes per request) | Small (session ID only) |
| CSRF risk | Low (Authorization header bypasses CSRF) | Higher — needs explicit CSRF tokens |
Use JWT when: building microservices, mobile APIs, or multi-service architectures where multiple independent services need to verify tokens without a shared database. JWT is also the natural choice for OAuth2 and OpenID Connect.
Use sessions when: building a traditional server-rendered web app where instant logout is critical, or when you need to store large amounts of user state server-side. Sessions are simpler to reason about for monolithic applications.
OAuth2 and OpenID Connect — id_token vs access_token, PKCE, and Providers
OAuth2 is an authorization framework; OpenID Connect (OIDC) adds an identity layer on top. Both use JWTs extensively. Understanding the difference between id_token and access_token is critical for correct implementation.
| Token | Purpose | Audience | Contains |
|---|---|---|---|
id_token | Authentication (who the user is) | Your application only | User identity claims (email, name, sub) |
access_token | Authorization (what can be accessed) | Resource server (API) | Scopes, permissions |
Authorization Code + PKCE Flow (SPAs and Mobile)
// Step 1: Generate PKCE code verifier and challenge
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// Step 2: Redirect to authorization URL
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', 'https://app.example.com/callback');
authUrl.searchParams.set('scope', 'openid email profile');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('state', crypto.randomBytes(16).toString('hex'));
// Step 3: Exchange authorization code for tokens
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: 'https://app.example.com/callback',
client_id: CLIENT_ID,
code_verifier: codeVerifier,
}),
});
const { access_token, id_token, refresh_token } = await tokenResponse.json();
// Step 4: Verify id_token via JWKS
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json')
);
const { payload } = await jwtVerify(id_token, JWKS, {
issuer: 'https://auth.example.com',
audience: CLIENT_ID,
});
console.log(payload.sub, payload.email);Auth0, Keycloak, and Supabase Integrations
// ─── Auth0 with Next.js ───────────────────────────────────────────────────────
npm install @auth0/nextjs-auth0
// app/api/auth/[auth0]/route.ts
import { handleAuth } from '@auth0/nextjs-auth0';
export const GET = handleAuth();
// Protected server component
import { getSession } from '@auth0/nextjs-auth0';
const session = await getSession();
// session.accessToken is the OAuth2 access_token (for calling APIs)
// session.user comes from the id_token claims
// ─── Supabase Auth (Next.js App Router) ──────────────────────────────────────
npm install @supabase/ssr @supabase/supabase-js
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
const supabase = createServerClient(URL, ANON_KEY, {
cookies: { getAll: () => cookies().getAll() }
});
const { data: { user } } = await supabase.auth.getUser();
// Supabase auto-handles JWT issuance, verification, and refresh
// ─── Keycloak JWT verification ────────────────────────────────────────────────
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs')
);
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://keycloak.example.com/realms/myrealm',
audience: 'my-client-id',
});Decode and inspect your JWTs instantly — paste any token into our online JWT decoder to view the header, payload, claims, expiry, and algorithm without any external tools.
Key Takeaways
- JWT = header.payload.signature: base64url-encoded, not encrypted — never put sensitive data in claims.
- Always set exp: tokens without expiry are valid forever — a critical security risk.
- Always specify algorithms: pass
{ algorithms: ['HS256'] }tojwt.verify()to prevent alg:none and algorithm confusion attacks. - Use RS256 for microservices: distribute the public key freely; keep the private key only on the auth server. Expose keys via a JWKS endpoint.
- Short-lived access tokens + refresh tokens: 15-minute access tokens with 7-day rotating refresh tokens stored in HttpOnly cookies.
- Never store JWTs in localStorage: XSS attacks can steal them. Use HttpOnly cookies or in-memory storage.
- Redis for revocation: track refresh token JTIs in Redis with TTL for instant logout and reuse detection.
- Use PKCE for SPAs: the authorization code + PKCE flow is the secure standard for browser-based OAuth2 clients.
- id_token vs access_token: id_token authenticates the user to your app; access_token authorizes calls to APIs.
- Managed providers (Auth0, Supabase, Keycloak) handle key rotation, JWKS, and token refresh automatically — prefer them for production.