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.
"typ":"JWT"}
"exp":1735689600}
header.payload,
SECRET)
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:
- Type-tag the tokens. A refresh token must not be accepted as an access token (and vice versa). Check
payload["type"]. - 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
expin seconds, not milliseconds. JWT spec uses Unix seconds.python-joseaccepts adatetimeand handles it; raw dicts don’t.- Don’t trust the
algfield. Always passalgorithms=[ALGO]todecode. 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-joseorPyJWT. Both fine.joseif we want JWE/JWS later.