JWT Auth

advanced fastapi auth jwt security

A JWT (JSON Web Token) is a string that contains a payload of claims, signed by the server. In simple language: it’s a tamper-proof note the server hands the client. The client shows it back on every request, and the server can verify “yes, I signed this” without hitting any DB.

That stateless property is the whole appeal. Sessions need a DB lookup per request. JWTs don’t.

Anatomy

Three base64url-encoded parts joined by dots: header.payload.signature.

JWT structure
header
{"alg":"HS256",
 "typ":"JWT"}
.
payload
{"sub":"manish",
 "exp":1735689600}
.
signature
HMAC_SHA256(
 header.payload,
 SECRET)
header + payload are just base64 — anyone can read them. The signature is what makes them tamper-proof.

The payload is not encrypted. Anyone with the token can decode it. Never put secrets in there — only put claims like user id, role, expiry.

Encoding and decoding

Two popular libraries: python-jose (used in FastAPI docs) and PyJWT. They have nearly identical APIs.

pip install "python-jose[cryptography]"
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError, ExpiredSignatureError

SECRET = "use-a-long-random-string-from-env"
ALGO = "HS256"

def encode_token(user_id: str, minutes: int = 30) -> str:
    payload = {
        "sub": user_id,
        "exp": datetime.now(timezone.utc) + timedelta(minutes=minutes),
        "iat": datetime.now(timezone.utc),
        "type": "access",
    }
    return jwt.encode(payload, SECRET, algorithm=ALGO)

def decode_token(token: str) -> dict:
    return jwt.decode(token, SECRET, algorithms=[ALGO])

jwt.decode checks the signature, the expiry (exp), and raises ExpiredSignatureError or generic JWTError if anything’s off. We don’t have to check expiry manually.

The get_current_user dependency

This is the bridge between “raw token in header” and “User object in route”. Most FastAPI apps have exactly this:

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

def get_current_user(token: str = Depends(oauth2_scheme)):
    creds_exc = HTTPException(
        status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = decode_token(token)
        user_id = payload.get("sub")
        if user_id is None:
            raise creds_exc
    except ExpiredSignatureError:
        raise HTTPException(401, "Token expired")
    except JWTError:
        raise creds_exc
    user = db.get_user(user_id)
    if not user:
        raise creds_exc
    return user

@app.get("/me")
def me(user = Depends(get_current_user)):
    return user

Every protected route just adds user = Depends(get_current_user). Composable, testable, swappable.

Refresh tokens — why two tokens?

Access tokens should be short-lived (15-30 min) so a leak isn’t catastrophic. But making users log in every 30 minutes is awful UX. Enter refresh tokens: long-lived (7-30 days), used only to get new access tokens.

The pattern:

@app.post("/token")
def login(form: OAuth2PasswordRequestForm = Depends()):
    user = authenticate(form.username, form.password)
    if not user:
        raise HTTPException(401)
    return {
        "access_token": encode_token(user.id, minutes=15),
        "refresh_token": encode_refresh(user.id, days=7),
        "token_type": "bearer",
    }

def encode_refresh(user_id: str, days: int) -> str:
    payload = {
        "sub": user_id,
        "exp": datetime.now(timezone.utc) + timedelta(days=days),
        "type": "refresh",
    }
    return jwt.encode(payload, SECRET, algorithm=ALGO)

class RefreshIn(BaseModel):
    refresh_token: str

@app.post("/refresh")
def refresh(body: RefreshIn):
    try:
        payload = decode_token(body.refresh_token)
        if payload.get("type") != "refresh":
            raise HTTPException(401, "wrong token type")
    except JWTError:
        raise HTTPException(401, "invalid refresh token")
    return {"access_token": encode_token(payload["sub"], minutes=15), "token_type": "bearer"}

Two big rules:

  1. Type-tag the tokens. A refresh token must not be accepted as an access token (and vice versa). Check payload["type"].
  2. Store refresh tokens server-side if we need revocation. Pure stateless JWTs can’t be revoked — that’s the tradeoff. For “log out everywhere”, keep a jti (token id) in the DB and check it.

Common pitfalls

  • exp in seconds, not milliseconds. JWT spec uses Unix seconds. python-jose accepts a datetime and handles it; raw dicts don’t.
  • Don’t trust the alg field. Always pass algorithms=[ALGO] to decode. There’s a famous “alg: none” attack otherwise.
  • Secret rotation. Plan for it. Accept two secrets during a rotation window.
  • Don’t put PII in the payload. It’s readable by anyone holding the token.

Interview cheat sheet

  • JWT = signed (not encrypted) JSON. Three parts: header.payload.signature.
  • Server can verify without DB lookup → stateless auth.
  • Access token short, refresh token long. Type-tag them.
  • Revocation needs a server-side allowlist/blocklist — pure JWTs can’t be revoked.
  • Always pin the algorithm on decode.
  • Use python-jose or PyJWT. Both fine. jose if we want JWE/JWS later.