I've implemented authentication in CitizenApp across multiple iterations, and I'm going to be blunt: most tutorials get this wrong. They'll show you a basic JWT implementation or a simple session setup, but they won't show you the real production patterns that keep your application secure without shooting yourself in the foot six months later.
Let me walk you through what actually works at scale, starting with the core decision that shapes everything else.
JWT vs Sessions: The Actual Trade-offs
Everyone treats this like a binary choice, but it's not. Here's what I learned the hard way:
JWT is stateless, which is its greatest strength and weakness. The appeal is obvious—no server-side session store, scales horizontally without coordination, works beautifully for distributed systems. But statelessness means you can't revoke a token until it expires. This burned me when a user in CitizenApp got compromised and I couldn't immediately invalidate their access. They had to wait 15 minutes for their token to expire.
Sessions are stateful but revocable. If someone gets hacked, you revoke the session immediately. The trade-off is you need a persistent store (Redis, database) and you're hitting it on every request to verify the session exists. At scale, this is fine—Redis is fast—but it's architectural overhead.
I prefer hybrid authentication: JWT for the access token (short-lived, 15 minutes), and a session-like refresh token stored server-side that's long-lived (7 days). This gives you revocation capability while keeping the horizontal scalability of JWTs.
Here's why: an attacker with a stolen short-lived JWT can't do much damage. A stolen refresh token? That's bad, but you can revoke it immediately. And you get the best of both worlds.
The FastAPI Implementation
Let me show you the actual pattern I use in CitizenApp. First, the token schemas:
from datetime import datetime, timedelta
from typing import Optional
from pydantic import BaseModel
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenPayload(BaseModel):
sub: int # user_id
exp: datetime
iat: datetime
type: str # "access" or "refresh"
Now the crucial part—generating both tokens with different expiration times:
from jose import JWTError, jwt
import os
SECRET_KEY = os.getenv("SECRET_KEY")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15
REFRESH_TOKEN_EXPIRE_DAYS = 7
def create_access_token(user_id: int) -> str:
now = datetime.utcnow()
expires = now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {
"sub": user_id,
"type": "access",
"iat": now.isoformat(),
"exp": expires.isoformat(),
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(user_id: int) -> str:
now = datetime.utcnow()
expires = now + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
payload = {
"sub": user_id,
"type": "refresh",
"iat": now.isoformat(),
"exp": expires.isoformat(),
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
The refresh token gets stored server-side. I use a simple RefreshToken table:
from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class RefreshTokenModel(Base):
__tablename__ = "refresh_tokens"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
token_hash = Column(String(255), unique=True, nullable=False) # hashed JWT
expires_at = Column(DateTime, nullable=False)
is_revoked = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
Why store it hashed? If your database gets compromised, tokens aren't immediately usable. Use SHA-256:
import hashlib
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def store_refresh_token(db: Session, user_id: int, token: str):
token_hash = hash_token(token)
expires_at = datetime.utcnow() + timedelta(days=7)
db_token = RefreshTokenModel(
user_id=user_id,
token_hash=token_hash,
expires_at=expires_at
)
db.add(db_token)
db.commit()
React 19 Implementation with Secure Storage
On the frontend, here's where most implementations fail: they store tokens in localStorage, which is vulnerable to XSS. I store the access token in memory, and the refresh token in an httpOnly cookie (set by FastAPI during login).
// hooks/useAuth.ts
import { useState, useCallback } from 'react';
interface AuthState {
user: User | null;
accessToken: string | null;
isLoading: boolean;
}
export function useAuth() {
const [authState, setAuthState] = useState<AuthState>({
user: null,
accessToken: null,
isLoading: false,
});
const login = useCallback(async (email: string, password: string) => {
setAuthState(prev => ({ ...prev, isLoading: true }));
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
credentials: 'include', // Send cookies
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) throw new Error('Login failed');
const data = await response.json();
setAuthState({
user: data.user,
accessToken: data.access_token,
isLoading: false,
});
} catch (error) {
setAuthState(prev => ({ ...prev, isLoading: false }));
throw error;
}
}, []);
return { ...authState, login };
}
The FastAPI login endpoint sets the refresh token as an httpOnly cookie:
from fastapi import Response
@router.post("/auth/login")
async def login(credentials: LoginSchema, response: Response, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == credentials.email).first()
if not user or not verify_password(credentials.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
access_token = create_access_token(user.id)
refresh_token = create_refresh_token(user.id)
await store_refresh_token(db, user.id, refresh_token)
# httpOnly, Secure, SameSite—the holy trinity
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=True, # HTTPS only
samesite="strict",
max_age=7 * 24 * 60 * 60,
)
return {
"user": user.dict(),
"access_token": access_token,
"token_type": "bearer",
}
Refresh Token Rotation
The real security magic happens when your access token expires. You call the refresh endpoint:
@router.post("/auth/refresh")
async def refresh(request: Request, db: Session = Depends(get_db)):
refresh_token = request.cookies.get("refresh_token")
if not refresh_token:
raise HTTPException(status_code=401, detail="No refresh token")
try:
payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
user_id = payload["sub"]
except JWTError:
raise HTTPException(status_code=401, detail="Invalid token")
# Check if token exists and isn't revoked
token_hash = hash_token(refresh_token)
db_token = db.query(RefreshTokenModel).filter(
RefreshTokenModel.token_hash == token_hash,
RefreshTokenModel.is_revoked == False,
).first()
if not db_token:
raise HTTPException(status_code=401, detail="Token revoked or expired")
# Issue new access token AND rotate refresh token
new_access_token = create_access_token(user_id)
new_refresh_token = create_refresh_token(user_id)
# Revoke old refresh token
db_token.is_revoked = True
db.commit()
# Store new one
await store_refresh_token(db, user_id, new_refresh_token)
response = JSONResponse({"access_token": new_access_token})
response.set_cookie(
key="refresh_token",
value=new_refresh_token,
httponly=True,
secure=True,
samesite="strict",
max_age=7 * 24 * 60 * 60,
)
return response
Gotcha: CSRF When Using Cookies
Here's what burned me: if you're storing the refresh token in cookies, you're vulnerable to CSRF attacks unless you implement CSRF protection. React doesn't automatically add CSRF tokens—you have to do it yourself.
When you make state-changing requests (POST, PUT, DELETE), extract the CSRF token from a meta tag or cookie and send it:
typescript
// Middleware to add CSRF token
Top comments (0)