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 user
  • POST /auth/login — exchange username/password for an access token + refresh token
  • POST /auth/refresh — exchange a refresh token for a new access token
  • GET /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.
  • exp is the expiration time — the JWT spec uses Unix timestamps, but python-jose accepts datetime objects directly.
  • We use a type claim 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:

  1. httpOnly, Secure, SameSite=Lax cookies. The token never touches JavaScript, so XSS can’t steal it. Best for browser apps.
  2. 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 exp validation. Tokens last forever and a leaked one is permanent. python-jose validates exp automatically — 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 .