JWT authentication is one of those things that everyone uses and very few people implement correctly. The basic idea is simple — give the client a signed token, ask for it back on every request — but the details (refresh tokens, where to store them, what to put in claims, how long to make them live) are where security incidents happen.
This post walks through a clean, production-shaped JWT auth setup for FastAPI. We’ll cover password hashing, issuing tokens, validating them on every request via dependency injection, and the pitfalls I see most often.
What we’re building
POST /auth/register— create a userPOST /auth/login— exchange username/password for an access token + refresh tokenPOST /auth/refresh— exchange a refresh token for a new access tokenGET /me— protected route returning the current user
We’ll build on the FastAPI + SQLAlchemy stack from the previous post .
Install dependencies
uv add python-jose[cryptography] passlib[bcrypt] python-multipart
python-jose— JWT encoding and decoding.passlib[bcrypt]— password hashing.python-multipart— required for OAuth2 form data parsing.
The User model
# app/models/user.py
from datetime import datetime
from sqlalchemy import String, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String, nullable=False)
is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
Run a migration (see the previous post ).
Password hashing
# app/core/security.py
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
# JWT helpers ────────────────────────────────────────────────────────────────
ALGORITHM = "HS256"
def create_access_token(subject: str | int, expires_delta: timedelta | None = None) -> str:
expire = datetime.now(tz=timezone.utc) + (
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
)
payload = {"sub": str(subject), "exp": expire, "type": "access"}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def create_refresh_token(subject: str | int) -> str:
expire = datetime.now(tz=timezone.utc) + timedelta(days=settings.refresh_token_expire_days)
payload = {"sub": str(subject), "exp": expire, "type": "refresh"}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def decode_token(token: str) -> dict[str, Any]:
try:
return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
except JWTError as e:
raise ValueError(f"Invalid token: {e}") from e
A few important details:
- bcrypt is the right choice for password hashing in 2026. Don’t use SHA-256 (too fast — easy to brute force).
- The
sub(subject) claim is the user ID. expis the expiration time — the JWT spec uses Unix timestamps, butpython-joseacceptsdatetimeobjects directly.- We use a
typeclaim to distinguish access from refresh tokens — so a refresh token can’t be used as an access token.
Update Settings:
# app/core/config.py
class Settings(BaseSettings):
# ...existing fields...
secret_key: str # 32+ random bytes; e.g. python -c "import secrets; print(secrets.token_urlsafe(32))"
access_token_expire_minutes: int = 15
refresh_token_expire_days: int = 7
SECRET_KEY=your-32-byte-random-string-do-not-share
Schemas
# app/schemas/auth.py
from pydantic import BaseModel, EmailStr, Field
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=50)
email: EmailStr
password: str = Field(min_length=8, max_length=128)
class UserRead(BaseModel):
id: int
username: str
email: EmailStr
is_active: bool
class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
The auth routes
# app/api/routes/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
from app.models.user import User
from app.schemas.auth import RefreshRequest, TokenPair, UserCreate, UserRead
router = APIRouter()
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def register(payload: UserCreate, db: AsyncSession = Depends(get_db)) -> User:
existing = await db.execute(
select(User).where((User.username == payload.username) | (User.email == payload.email))
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Username or email already taken")
user = User(
username=payload.username,
email=payload.email,
hashed_password=hash_password(payload.password),
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.post("/login", response_model=TokenPair)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
) -> TokenPair:
result = await db.execute(select(User).where(User.username == form_data.username))
user = result.scalar_one_or_none()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
if not user.is_active:
raise HTTPException(status_code=403, detail="User is inactive")
return TokenPair(
access_token=create_access_token(user.id),
refresh_token=create_refresh_token(user.id),
)
@router.post("/refresh", response_model=TokenPair)
async def refresh(payload: RefreshRequest) -> TokenPair:
try:
claims = decode_token(payload.refresh_token)
except ValueError as e:
raise HTTPException(status_code=401, detail=str(e)) from e
if claims.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Wrong token type")
user_id = claims["sub"]
return TokenPair(
access_token=create_access_token(user_id),
refresh_token=create_refresh_token(user_id),
)
OAuth2PasswordRequestForm is FastAPI’s helper for parsing the standard OAuth2 password-grant form. It pairs nicely with the auto-generated Swagger UI’s “Authorize” button.
The get_current_user dependency
This is the magic that makes every protected route a one-liner:
# app/api/deps.py (additions)
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decode_token
from app.models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
claims = decode_token(token)
except ValueError as e:
raise credentials_error from e
if claims.get("type") != "access":
raise credentials_error
user_id = claims.get("sub")
if not user_id:
raise credentials_error
user = await db.get(User, int(user_id))
if not user or not user.is_active:
raise credentials_error
return user
Now a protected route is trivially short:
# app/api/routes/me.py
from fastapi import APIRouter, Depends
from app.api.deps import get_current_user
from app.models.user import User
from app.schemas.auth import UserRead
router = APIRouter()
@router.get("/me", response_model=UserRead)
async def read_me(user: User = Depends(get_current_user)) -> User:
return user
That’s the entire payoff: the dependency runs on every request, validates the token, fetches the user, and hands it to your route. If anything is wrong, you get a 401 automatically.
Wire up the app
# app/main.py
from fastapi import FastAPI
from app.api.routes import auth, me, tasks
app = FastAPI()
app.include_router(auth.router, prefix="/auth", tags=["auth"])
app.include_router(me.router, tags=["me"])
app.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
Trying it
# Register
curl -X POST localhost:8000/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"alzy","email":"[email protected]","password":"strongpass123"}'
# Login (note: form data, not JSON!)
curl -X POST localhost:8000/auth/login \
-d "username=alzy&password=strongpass123"
# → {"access_token":"eyJ...","refresh_token":"eyJ...","token_type":"bearer"}
# Use it
curl localhost:8000/me -H "Authorization: Bearer <ACCESS_TOKEN>"
# → {"id":1,"username":"alzy","email":"[email protected]","is_active":true}
Where to store tokens client-side
This is where most apps go wrong. Two safe-enough options:
- httpOnly, Secure, SameSite=Lax cookies. The token never touches JavaScript, so XSS can’t steal it. Best for browser apps.
- In-memory only, with refresh on page load. The token disappears on tab close. Annoying for users; very secure.
Don’t use localStorage unless you’ve thought hard about XSS. Any script (including a compromised npm dependency) can read it.
For mobile apps, store in the platform’s secure storage (Keychain on iOS, EncryptedSharedPreferences on Android).
Refresh token rotation
When a refresh token is used, issue a new one and invalidate the old. This means:
- A stolen refresh token gets one use before the legitimate user’s next refresh invalidates it (and they detect it).
- You need a refresh token store (Redis or DB) so you can revoke them.
The simple /auth/refresh above doesn’t do rotation — for production, add it. The added complexity is real, but the security win is real too.
Common pitfalls
- No
expvalidation. Tokens last forever and a leaked one is permanent.python-josevalidatesexpautomatically — just don’t set it to a year out. - Symmetric vs asymmetric algorithms. HS256 (used here) is symmetric — your secret signs and verifies. RS256 is asymmetric — you sign with a private key, others verify with a public key. For services that share tokens across boundaries, prefer RS256.
- Putting too much in the JWT. JWTs are not encrypted (just signed). Don’t put secrets, PII, or anything you don’t want the user to see — they can decode the payload trivially.
- Long-lived access tokens. Keep them short (5–15 minutes). Use refresh tokens for the long-lived part.
- No revocation. JWTs are stateless — once issued they’re valid until expiry. To revoke, either (a) maintain a server-side denylist of token IDs, or (b) keep access tokens short and revoke at the refresh-token level.
Conclusion
JWT auth in FastAPI takes about 200 lines of code to get right. The hard parts aren’t the libraries — they’re the security decisions: password hashing algorithm, token lifetimes, where to store tokens client-side, refresh rotation. Get those right and you have an auth system that will hold up in production.
If your needs are more complex (OAuth providers, MFA, SSO), seriously consider a managed auth service. The undifferentiated heavy lifting isn’t worth your time.
Continuing the FastAPI series:
Stay safe out there.
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .