JSON Web Tokens are everywhere. They're the de facto standard for stateless API authentication and they're also one of the most misunderstood and misimplemented security mechanisms in web development.
This article covers JWT from first principles, including common mistakes that create security vulnerabilities, and walks through a complete, production-ready implementation in Python with FastAPI.
What Is a JWT?
A JSON Web Token (JWT) is a compact, URL-safe token that encodes a JSON payload and optionally signs or encrypts it. It's used to transmit verified claims between parties most commonly, "this user is authenticated and has these permissions."
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6MTcxNjY1MDAwMH0.3rW7TE8iCCiLBOBs6nLBpWxXuV3zqkL9mYpC2HBkd4s
It's three Base64URL-encoded segments joined by dots:
HEADER.PAYLOAD.SIGNATURE
Header
{
"alg": "HS256",
"typ": "JWT"
}
Specifies the algorithm used to sign the token.
Payload (Claims)
{
"sub": "user_123",
"name": "Alice",
"role": "admin",
"iat": 1716563600,
"exp": 1716650000
}
Standard claims include:
-
sub— subject (typically a user ID) -
iat— issued at -
exp— expiry -
nbf— not before -
iss— issuer -
aud— audience
The payload is NOT encrypted by default. It's only Base64-encoded, which is trivially reversible. Never store sensitive data (passwords, credit card numbers) in a JWT payload.
Signature
HMACSHA256(
base64url(header) + "." + base64url(payload),
secret_key
)
The signature is what makes JWTs trustworthy. The server verifies that nobody tampered with the payload by recomputing the signature and comparing it.
How JWT Authentication Works
1. User logs in with credentials (username + password)
2. Server validates credentials, issues a signed JWT
3. Client stores the JWT (usually in memory or localStorage)
4. Client sends JWT in the Authorization header on subsequent requests:
Authorization: Bearer <token>
5. Server verifies the signature and extracts the claims
6. No database lookup required — the token is self-contained
This is what "stateless" authentication means: the server doesn't need to store session data. All the information is in the token itself.
JWT vs Sessions: When to Use Each
| JWT | Sessions | |
|---|---|---|
| Storage | Client-side | Server-side |
| Scalability | Easy (no shared state) | Harder (needs shared session store) |
| Revocation | Hard (tokens are valid until expiry) | Easy (delete from store) |
| Size | Larger (header on every request) | Small (just a session ID) |
| Best for | Stateless APIs, microservices | Traditional web apps with logout |
JWTs are great for APIs. For apps where you need instant revocation (logout that actually works), session-based auth or short-lived JWTs + refresh tokens is better.
Implementation: FastAPI + JWT
Dependencies
pip install fastapi uvicorn python-jose[cryptography] passlib[bcrypt] python-multipart
-
python-jose: JWT encoding/decoding -
passlib: Password hashing with bcrypt
Project Structure
app/
├── main.py
├── auth.py # JWT logic
├── models.py # Pydantic models
└── database.py # Fake DB for demo
auth.py — Core JWT Logic
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
# Configuration — store these in environment variables in production!
SECRET_KEY = "your-super-secret-key-change-this-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_DAYS = 7
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> dict:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
return None
models.py
from pydantic import BaseModel
from typing import Optional
class UserLogin(BaseModel):
username: str
password: str
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
user_id: Optional[str] = None
role: Optional[str] = None
database.py (Mock DB)
from auth import hash_password
# In production, replace with real database queries
FAKE_USERS_DB = {
"alice": {
"id": "user_001",
"username": "alice",
"hashed_password": hash_password("secret123"),
"role": "admin",
"is_active": True,
},
"bob": {
"id": "user_002",
"username": "bob",
"hashed_password": hash_password("password456"),
"role": "user",
"is_active": True,
},
}
def get_user(username: str):
return FAKE_USERS_DB.get(username)
main.py — The Full Application
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from auth import (
verify_password, create_access_token, create_refresh_token, decode_token
)
from database import get_user
from models import UserLogin, Token
app = FastAPI(title="JWT Auth Demo")
security = HTTPBearer()
# Authentication
@app.post("/auth/login", response_model=Token)
def login(credentials: UserLogin):
user = get_user(credentials.username)
if not user or not verify_password(credentials.password, user["hashed_password"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user["is_active"]:
raise HTTPException(status_code=400, detail="Inactive user")
token_data = {"sub": user["id"], "username": user["username"], "role": user["role"]}
return Token(
access_token=create_access_token(token_data),
refresh_token=create_refresh_token(token_data),
)
@app.post("/auth/refresh", response_model=Token)
def refresh_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
payload = decode_token(credentials.credentials)
if not payload or payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
)
token_data = {
"sub": payload["sub"],
"username": payload["username"],
"role": payload["role"],
}
return Token(
access_token=create_access_token(token_data),
refresh_token=create_refresh_token(token_data),
)
# Dependency: Get Current User
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
payload = decode_token(credentials.credentials)
if not payload or payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user = get_user(payload.get("username"))
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
def require_admin(current_user: dict = Depends(get_current_user)):
if current_user["role"] != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return current_user
# Protected Routes
@app.get("/me")
def get_profile(current_user: dict = Depends(get_current_user)):
return {
"id": current_user["id"],
"username": current_user["username"],
"role": current_user["role"],
}
@app.get("/admin/dashboard")
def admin_dashboard(admin: dict = Depends(require_admin)):
return {"message": f"Welcome, {admin['username']}. Here's the admin dashboard."}
@app.get("/public")
def public_route():
return {"message": "This route requires no authentication"}
Testing It
uvicorn main:app --reload
Login:
curl -X POST http://localhost:8000/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "secret123"}'
Use the access token:
curl http://localhost:8000/me \
-H "Authorization: Bearer <your_access_token>"
Common Security Mistakes and How to Avoid Them
Using a weak or hardcoded secret key
# BAD
SECRET_KEY = "secret"
# GOOD — generate with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY = os.environ["JWT_SECRET_KEY"]
Using the none algorithm
Some early JWT libraries had a bug where alg: none would skip signature verification. Always explicitly specify allowed algorithms:
jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) # whitelist only
Storing JWTs in localStorage
localStorage is accessible to any JavaScript on the page, making it vulnerable to XSS attacks. Use httpOnly cookies for web apps, or keep tokens in memory for SPAs.
Long-lived access tokens without refresh tokens
If an access token is compromised, you want it to expire quickly. Use short-lived access tokens (15–30 minutes) and longer-lived refresh tokens (7–30 days).
Not validating the exp claim
The python-jose library validates expiry automatically when decoding, but make sure you're not using options={"verify_exp": False} in production.
Putting sensitive data in the payload
The payload is Base64-encoded, not encrypted. Decode any JWT at jwt.io to see this in action. Only store non-sensitive identifiers (user ID, role).
Token Revocation: The Hard Problem
JWTs are stateless by design - there's no built-in revocation mechanism. If a token is stolen, it remains valid until it expires. Solutions:
- Short expiry - 15-minute access tokens minimize the damage window
- Refresh token rotation - issue a new refresh token on every refresh, invalidate the old one
-
Denylist - maintain a set of revoked token IDs (the
jticlaim) in Redis. Defeats some of the stateless benefit but gives you real revocation.
import redis
import uuid
r = redis.Redis()
def create_access_token(data: dict) -> str:
to_encode = data.copy()
to_encode.update({
"jti": str(uuid.uuid4()), # unique token ID
"exp": datetime.now(timezone.utc) + timedelta(minutes=15),
})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def is_token_revoked(jti: str) -> bool:
return r.exists(f"revoked:{jti}")
def revoke_token(jti: str, ttl_seconds: int):
r.setex(f"revoked:{jti}", ttl_seconds, "1")
Summary
JWT authentication is powerful when implemented correctly. The key things to remember:
- Never store sensitive data in the payload
- Use environment variables for secret keys
- Keep access tokens short-lived, pair with refresh tokens
- Whitelist algorithms when decoding
- Understand the revocation tradeoff and design accordingly
The implementation above gives you a solid production foundation. From here, you can add email verification, OAuth2 social login, or 2FA on top of the same JWT infrastructure.
Top comments (0)