A JSON Web Token (JWT) is a compact, URL-safe token that carries a signed JSON payload. It lets a server issue credentials that any service can verify without querying a database — which is why JWTs show up in nearly every modern authentication system.
How a JWT Is Structured
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjMiLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDAwMzYwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Three Base64url-encoded parts separated by dots:
HEADER.PAYLOAD.SIGNATURE
Header
{
"alg": "HS256",
"typ": "JWT"
}
alg is the signing algorithm. Common values:
| Algorithm | Type | Notes |
|---|---|---|
HS256 |
HMAC-SHA256 | Symmetric — same secret signs and verifies |
HS384 |
HMAC-SHA384 | Symmetric, longer hash |
RS256 |
RSA-SHA256 | Asymmetric — private key signs, public key verifies |
ES256 |
ECDSA-SHA256 | Asymmetric, smaller keys than RSA |
none |
No signature | Never use in production |
Payload (Claims)
{
"sub": "123",
"name": "Alice",
"role": "admin",
"iat": 1700000000,
"exp": 1700003600
}
Registered claims (standardised, optional but recommended):
| Claim | Meaning |
|---|---|
sub |
Subject — who the token is about (user ID) |
iss |
Issuer — who created the token |
aud |
Audience — who should accept the token |
exp |
Expiry timestamp (Unix seconds) |
iat |
Issued-at timestamp |
nbf |
Not-valid-before timestamp |
jti |
JWT ID — unique token identifier |
Add any custom fields you need (email, role, permissions).
Important: The payload is Base64url-encoded, not encrypted. Anyone who holds the token can read it. Never put passwords, PII you want hidden, or secrets in the payload.
Signature
HMACSHA256(
base64url(header) + "." + base64url(payload),
secret
)
The signature proves the token was issued by someone who holds the secret (HS*) or private key (RS*, ES*). If even one byte of header or payload changes, verification fails.
Quick reference
| Task | One-liner |
|---|---|
| Decode payload (no verify) | atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')) |
| Inspect online | Paste token at jwt.io |
| Check expiry | exp claim is Unix timestamp — compare to Date.now()/1000 |
| Sign with HS256 (Node) | jwt.sign(payload, secret, { expiresIn: '1h' }) |
| Verify (Node) | jwt.verify(token, secret) |
| Sign with RS256 | jwt.sign(payload, privateKey, { algorithm: 'RS256' }) |
| Decode without verify (Python) | jwt.decode(token, options={"verify_signature": False}) |
JWT in Node.js
Install
npm install jsonwebtoken
Sign a token
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET; // minimum 32 random bytes
const token = jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role,
},
SECRET,
{
expiresIn: '1h', // '15m', '7d', 3600 (seconds)
issuer: 'my-api',
audience: 'my-frontend',
}
);
Verify a token
try {
const payload = jwt.verify(token, SECRET, {
issuer: 'my-api',
audience: 'my-frontend',
});
console.log(payload.sub); // "123"
} catch (err) {
if (err.name === 'TokenExpiredError') {
// handle expired
} else if (err.name === 'JsonWebTokenError') {
// handle invalid signature / malformed
}
}
Express middleware
function requireAuth(req, res, next) {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
try {
req.user = jwt.verify(auth.slice(7), SECRET);
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}
app.get('/profile', requireAuth, (req, res) => {
res.json({ userId: req.user.sub });
});
Refresh token pattern
// Issue both tokens at login
function issueTokens(userId) {
const accessToken = jwt.sign({ sub: userId }, SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign({ sub: userId }, REFRESH_SECRET, { expiresIn: '7d' });
return { accessToken, refreshToken };
}
// Refresh endpoint
app.post('/auth/refresh', (req, res) => {
const { refreshToken } = req.body;
try {
const payload = jwt.verify(refreshToken, REFRESH_SECRET);
const accessToken = jwt.sign({ sub: payload.sub }, SECRET, { expiresIn: '15m' });
res.json({ accessToken });
} catch {
res.status(401).json({ error: 'Invalid refresh token' });
}
});
JWT in Python
Install
pip install PyJWT
Sign and verify
import jwt
from datetime import datetime, timedelta, timezone
SECRET = "your-secret-key"
# Sign
payload = {
"sub": "123",
"email": "alice@example.com",
"role": "admin",
"iat": datetime.now(timezone.utc),
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
}
token = jwt.encode(payload, SECRET, algorithm="HS256")
# Verify
try:
decoded = jwt.decode(token, SECRET, algorithms=["HS256"])
print(decoded["sub"])
except jwt.ExpiredSignatureError:
print("Token expired")
except jwt.InvalidTokenError:
print("Invalid token")
FastAPI dependency
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt
security = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
):
try:
payload = jwt.decode(
credentials.credentials,
SECRET,
algorithms=["HS256"],
)
return payload
except jwt.PyJWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
@app.get("/me")
def me(user = Depends(get_current_user)):
return {"userId": user["sub"]}
RS256 with key pair
from pathlib import Path
private_key = Path("private.pem").read_bytes()
public_key = Path("public.pem").read_bytes()
# Sign with private key
token = jwt.encode(payload, private_key, algorithm="RS256")
# Verify with public key (can be distributed freely)
decoded = jwt.decode(token, public_key, algorithms=["RS256"])
JWT in Go
package main
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
var secret = []byte("your-secret-key")
type Claims struct {
UserID string `json:"sub"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// Sign
func signToken(userID, role string) (string, error) {
claims := Claims{
UserID: userID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}
// Verify
func verifyToken(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
The full authentication flow
Client Server
| |
|-- POST /login (email+pass) -->|
| |-- verify credentials
| |-- issue accessToken (15m)
| |-- issue refreshToken (7d, store in DB)
|<-- { accessToken, refreshToken } --|
| |
|-- GET /api/data |
| Authorization: Bearer <accessToken>
| |-- jwt.verify(accessToken, SECRET)
|<-- 200 { data } |
| |
| (accessToken expires) |
|-- POST /auth/refresh |
| { refreshToken } |
| |-- jwt.verify(refreshToken, REFRESH_SECRET)
| |-- check refreshToken exists in DB
| |-- issue new accessToken
|<-- { accessToken } |
Where to store tokens
| Storage location | XSS risk | CSRF risk | Notes |
|---|---|---|---|
localStorage |
High | None | Accessible to any JS on the page |
sessionStorage |
High | None | Cleared on tab close, still XSS-exposed |
| Memory (JS variable) | Low | None | Lost on refresh; good for short-lived access tokens |
httpOnly cookie |
None | Medium | Best for refresh tokens; not readable by JS |
httpOnly + SameSite=Strict |
None | Low | Best overall for server-rendered apps |
Recommended:
- Access token → memory (JavaScript variable)
- Refresh token →
httpOnly; Secure; SameSite=Strictcookie
JWT vs Session tokens
| JWT | Session | |
|---|---|---|
| Storage | Client holds the token | Server stores session in DB/cache |
| Scalability | Stateless — no DB lookup | Requires shared session store |
| Revocation | Hard — must wait for expiry or maintain denylist | Instant — delete from store |
| Payload size | Grows with claims | Just a random ID |
| Best for | Microservices, APIs, mobile | Traditional web apps, when instant logout matters |
| Standard | RFC 7519 | Implementation-specific |
Use JWT when:
- Building a stateless REST API
- Multiple services need to verify identity without a central DB call
- Clients are mobile apps or SPAs
Use sessions when:
- You need instant revocation (financial apps, security-sensitive)
- The payload would be large
- You're building a server-rendered web app
JWT vs OAuth 2.0
These are not alternatives — they solve different problems:
| JWT | OAuth 2.0 | |
|---|---|---|
| What it is | Token format | Authorization protocol |
| What it does | Encodes and signs claims | Defines how to delegate access |
| Used together? | Yes — OAuth 2.0 often issues JWTs | JWT is a common OAuth 2.0 token format |
OAuth 2.0 answers "how does the server issue the token?" JWT answers "what format should the token be?"
Security best practices
Algorithm:
// Always specify the algorithm explicitly
jwt.verify(token, secret, { algorithms: ['HS256'] });
// If you omit algorithms, an attacker can forge tokens using alg:"none"
Secret strength:
# Generate a strong secret
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
openssl rand -hex 64
Short expiry for access tokens:
// Access token: 5-15 minutes
{ expiresIn: '15m' }
// Refresh token: days to weeks, stored in DB so it can be revoked
{ expiresIn: '7d' }
Validate all claims:
jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'my-api', // rejects tokens from other issuers
audience: 'my-frontend', // rejects tokens meant for others
});
Token rotation on refresh:
// When issuing a new access token from a refresh token:
// 1. Verify the refresh token
// 2. Delete old refresh token from DB
// 3. Issue new refresh token + new access token
// This detects refresh token theft via refresh token rotation
Revocation / denylist:
// Store revoked JTI values in Redis with TTL = token expiry
async function isRevoked(token) {
const { jti } = jwt.decode(token);
return await redis.exists(`revoked:${jti}`);
}
async function revokeToken(token) {
const { jti, exp } = jwt.decode(token);
const ttl = exp - Math.floor(Date.now() / 1000);
await redis.setex(`revoked:${jti}`, ttl, '1');
}
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
alg: "none" accepted |
Forged tokens bypass signature check | Always pass algorithms array to verify |
Storing access token in localStorage |
XSS can steal token | Use httpOnly cookie or memory |
No expiry (exp claim) |
Stolen tokens valid forever | Always set expiresIn |
| Long-lived access tokens (days) | Wide window if stolen | Use 15m access + refresh token pattern |
| Putting secrets in payload | Payload is readable by anyone | Payload is Base64, not encrypted |
| Using same secret for access + refresh | Compromise of one compromises both | Use separate secrets |
Not validating iss and aud |
Tokens from other systems accepted | Always validate registered claims |
Storing refresh token in localStorage |
XSS can steal and reuse it | Use httpOnly cookie only |
Debugging a JWT
# Decode payload without verifying (shows structure)
echo "eyJ..." | cut -d'.' -f2 | base64 -d 2>/dev/null | python3 -m json.tool
# Node.js
node -e "console.log(JSON.stringify(require('jsonwebtoken').decode('TOKEN'), null, 2))"
# Python
python3 -c "
import jwt, sys
t = sys.argv[1]
print(jwt.decode(t, options={'verify_signature': False}))
" YOUR_TOKEN_HERE
# Check expiry
node -e "
const p = JSON.parse(Buffer.from('PAYLOAD_PART', 'base64url'));
console.log('exp:', new Date(p.exp * 1000));
console.log('expired:', p.exp < Date.now()/1000);
"
FAQ
Q: Can I invalidate a JWT before it expires?
Not without extra infrastructure. JWTs are stateless by design. The standard approaches are: (1) keep access tokens short-lived (15m), (2) maintain a Redis denylist of revoked JTI values, or (3) use opaque tokens instead of JWTs where instant revocation is required.
Q: Is the payload encrypted? Can users read it?
No. The payload is Base64url-encoded, not encrypted. Anyone with the token can decode and read the payload. If you need confidentiality, use JWE (JSON Web Encryption) or don't put sensitive data in the payload.
Q: What's the difference between HS256 and RS256?
HS256 uses a single symmetric secret — the same key signs and verifies. RS256 uses an RSA key pair — the private key signs (kept secret on the auth server), the public key verifies (can be distributed to any service). Use RS256 when multiple independent services need to verify tokens without sharing a secret.
Q: How big can a JWT get?
JWTs travel in HTTP headers, which have a practical limit of ~8KB depending on the server. Keep payloads small — user ID, role, and a few claims. Don't embed full user profiles or permission lists.
Q: Should I use JWT for sessions in a web app?
It depends. For a traditional server-rendered web app where instant logout matters, session cookies with a database/Redis backend are simpler and safer. For a stateless REST API or microservices, JWT is a better fit. The "JWT everywhere" pattern is often overkill.
Q: What happens if my JWT secret leaks?
Rotate immediately: generate a new secret, redeploy the auth server, and invalidate all existing tokens. Because JWTs are stateless, there's no way to invalidate existing tokens without a denylist — so users will need to log in again. This is why short-lived access tokens matter.