0x74 0x68 0x65 0x72 0x65 0x20 0x61 0x72 0x65 0x20 0x31 0x30 0x20 0x74 0x79 0x70 0x65 0x73 0x20 0x6f 0x66 0x20 0x70 0x65 0x6f 0x70 0x6c 0x65 0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x75 0x6e 0x64 0x65 0x72 0x73 0x74 0x61 0x6e 0x64 0x20 0x62 0x69 0x6e 0x61 0x72 0x79 0x20 0x61 0x6e 0x64 0x20 0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x64 0x6f 0x6e 0x74
01110100 01101000 01100101 01110010 01100101 00100000 01100001 01110010 01100101 00100000 00110001 00110000 00100000 01110100 01111001 01110000 01100101 01110011 0xdeadbeef 0xcafebabe 0x1337
0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x75 0x6e 0x64 0x65 0x72 0x73 0x74 0x61 0x6e 0x64 0x20 0x62 0x69 0x6e 0x61 0x72 0x79 0x20 0x61 0x6e 0x64 0x20 0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x64 0x6f 0x6e 0x74 0x74 0x68 0x65 0x72 0x65 0x20 0x61 0x72 0x65 0x20 0x31 0x30 0x20 0x74 0x79 0x70 0x65 0x73 0x20 0x6f 0x66 0x20 0x70 0x65 0x6f 0x70 0x6c 0x65
01010111 01100101 00100000 01101000 01100001 01100011 01101011 00100000 01110100 01101000 01100101 00100000 01110000 01101100 01100001 01101110 01100101 01110100 01100110 00110000 01111000 01100110 00110100 01100100 01100101
0x6e 0x65 0x76 0x65 0x72 0x20 0x67 0x6f 0x6e 0x6e 0x61 0x20 0x64 0x72 0x6f 0x70 0x20 0x79 0x6f 0x75 0x72 0x20 0x73 0x68 0x65 0x6c 0x6c 0x20 0x6e 0x65 0x76 0x65 0x72 0x20 0x67 0x6f 0x6e 0x6e 0x61 0x20 0x6b 0x69 0x6c 0x6c 0x20 0x79 0x6f 0x75 0x72 0x20 0x74 0x68 0x72 0x65 0x61 0x64 0x20 0x6e 0x65 0x76 0x65 0x72 0x20 0x67 0x6f 0x6e 0x6e 0x61 0x20 0x6c 0x6f 0x73 0x65 0x20 0x79 0x6f 0x75 0x72 0x20 0x70 0x61 0x63 0x6b 0x65 0x74 0x20 0x61 0x6e 0x64 0x20 0x64 0x65 0x73 0x65 0x72 0x74 0x20 0x79 0x6f 0x75 0x72 0x20 0x71 0x75 0x65 0x75 0x65
Published on

Securing FastAPI with OAuth2 and JWT

Securing FastAPI with OAuth2 and JWT

Welcome back! At this point we have a working API and a React frontend talking to it. If you have been following along, you already know the obvious problem: anyone who can reach the API can read, create, update, and delete every todo in the database.

Time to fix that.

In this post we will add authentication using FastAPI's built-in OAuth2 support with JWT tokens. We will cover how the pieces fit together, what the actual security properties are, and where this approach falls short so you know when to reach for an SSO provider like Keycloak or Authentik instead (both covered in follow-up posts).


Background Reading

Authentication has a lot of moving parts. If OAuth2, JWTs, or hashing are new to you, these are worth reading before the code sections.

OAuth2 and Auth Flows

  • OAuth 2.0 Simplified. The clearest plain-English explanation of OAuth2 flows available. Read the password flow and authorization code flow sections.
  • Auth0: An Introduction to OAuth 2.0. Good overview of the grant types and when each is appropriate.
  • RFC 6749. The actual spec if you want the authoritative source. Dense but complete.

JWT

  • jwt.io Introduction. Explains the header/payload/signature structure clearly.
  • jwt.ms. Paste a token and see it decoded. Useful for debugging throughout this post.

Password Hashing


How This Flow Works

Before touching any code, it is worth understanding what we are actually building.

OAuth2 Password Flow is a grant type where the client sends a username and password directly to the authorization server (in this case, our own API) and gets a token back. The client then includes that token in every subsequent request.

The token we issue is a JWT (JSON Web Token). A JWT is a base64-encoded, cryptographically signed payload. The signature lets the server verify the token was issued by us without looking it up in the database on every request. That is what makes JWTs stateless. The token itself carries the user's identity.

JWTs can be viewed and decoded at jwt.ms

The flow looks like this:

  1. User posts credentials to /auth/token
  2. We verify the password hash against what is stored in the database
  3. We issue a signed JWT with an expiry
  4. The client stores the token and sends it in the Authorization: Bearer <token> header on protected routes
  5. We verify the signature and expiry on each request and extract the user identity

What this does not give you. There is no built-in token revocation. If a token is leaked, it remains valid until it expires. Short expiry times (15 to 60 minutes) are the main mitigation, combined with refresh tokens for longer sessions. More on that in the tradeoffs section.


Dependencies

Add these to your requirements.txt and install them:

python-jose[cryptography]
passlib[bcrypt]
python-multipart
pip install -r requirements.txt

Project Structure

We will add the following files to the API project:

todo-api/
  auth/
    __init__.py
    router.py       # /auth/token endpoint
    dependencies.py # get_current_user dependency
    utils.py        # password hashing and JWT helpers
  models.py         # add User model
  schemas.py        # add User schemas
  ...

Generating a Secret Key

JWT tokens are signed with a secret key. It needs to be long, random, and never committed to source control.

openssl rand -hex 32

Add it to your .env file:

SECRET_KEY=your_generated_key_here
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30

HS256 is HMAC-SHA256, a symmetric signing algorithm. Both signing and verification use the same key. For most internal APIs this is fine. If you need multiple services to verify tokens without sharing the signing key, look into RS256.


Auth Utilities

Create auth/utils.py:

from os import getenv
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext

SECRET_KEY = getenv("SECRET_KEY")
ALGORITHM = getenv("ALGORITHM", "HS256")
ACCESS_TOKEN_EXPIRE_MINUTES = int(getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def hash_password(password: str) -> str:
    return pwd_context.hash(password)


def verify_password(plain: str, hashed: str) -> bool:
    return pwd_context.verify(plain, hashed)


def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
    payload = data.copy()
    expire = datetime.now(timezone.utc) + (
        expires_delta if expires_delta else timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    )
    payload.update({"exp": expire})
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)


def decode_access_token(token: str) -> dict:
    return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])

Why bcrypt? Bcrypt is slow by design. That is exactly what you want for passwords. It is expensive to brute force, and the cost factor can be increased over time as hardware gets faster. Never store passwords with MD5, SHA1, or plain SHA256. Those are fast hashing algorithms built for data integrity, not password storage.


User Model and Schema

Add a User table to models.py:

from sqlalchemy import Column, String, Boolean
import uuid
from database import Base

class User(Base):
    __tablename__ = "users"

    id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
    username = Column(String(100), unique=True, nullable=False, index=True)
    hashed_password = Column(String, nullable=False)
    is_active = Column(Boolean, default=True, nullable=False)

Add the corresponding schemas to schemas.py:

class UserCreate(BaseModel):
    username: str
    password: str

class UserResponse(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: str
    username: str
    is_active: bool

class Token(BaseModel):
    access_token: str
    token_type: str

class TokenData(BaseModel):
    username: Optional[str] = None

Run a new Alembic migration to create the users table:

alembic revision --autogenerate -m "add users table"
alembic upgrade head

Auth Dependencies

Create auth/dependencies.py. This is the reusable dependency that protects any route that requires an authenticated user.

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError
from sqlalchemy.orm import Session
from database import get_db
from models import User
from schemas import TokenData
from auth.utils import decode_access_token

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")


def get_current_user(
    token: str = Depends(oauth2_scheme),
    db: Session = Depends(get_db),
) -> User:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = decode_access_token(token)
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
        token_data = TokenData(username=username)
    except JWTError:
        raise credentials_exception

    user = db.query(User).filter(User.username == token_data.username).first()
    if user is None or not user.is_active:
        raise credentials_exception
    return user

OAuth2PasswordBearer tells FastAPI where the token endpoint lives. When a protected route is hit without a valid token, FastAPI automatically returns a 401 with a WWW-Authenticate: Bearer header, which is what the OAuth2 spec requires.


Auth Router

Create auth/router.py:

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from datetime import timedelta
from database import get_db
from models import User
from schemas import Token, UserCreate, UserResponse
from auth.utils import hash_password, verify_password, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES

router = APIRouter(prefix="/auth", tags=["auth"])


@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def register(payload: UserCreate, db: Session = Depends(get_db)):
    existing = db.query(User).filter(User.username == payload.username).first()
    if existing:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Username already registered",
        )
    user = User(
        username=payload.username,
        hashed_password=hash_password(payload.password),
    )
    db.add(user)
    db.commit()
    db.refresh(user)
    return user


@router.post("/token", response_model=Token)
def login(
    form_data: OAuth2PasswordRequestForm = Depends(),
    db: Session = Depends(get_db),
):
    user = db.query(User).filter(User.username == form_data.username).first()
    if not user or not verify_password(form_data.password, user.hashed_password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    access_token = create_access_token(
        data={"sub": user.username},
        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
    )
    return {"access_token": access_token, "token_type": "bearer"}

A couple of things worth calling out here.

The sub claim. JWT has a set of registered claims. We use sub (subject) to store the username. You will see this convention everywhere.

Do not leak why login failed. The error says "Incorrect username or password", not "User not found" or "Wrong password". Revealing which one is correct lets attackers enumerate valid usernames.


Protecting Routes

Wire the auth router into main.py and protect the todos routes:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from database import engine
from models import Base
from routes import router as todos_router
from auth.router import router as auth_router

Base.metadata.create_all(bind=engine)

app = FastAPI(title="Todo API")

origins = ["http://localhost:5173"]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.include_router(auth_router)
app.include_router(todos_router)

Update routes.py to require authentication on every todo endpoint:

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from database import get_db
from models import Todo, User
from schemas import TodoCreate, TodoUpdate, TodoResponse
from auth.dependencies import get_current_user
from typing import List

router = APIRouter(prefix="/todos", tags=["todos"])


@router.get("/", response_model=List[TodoResponse])
def get_todos(
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    return db.query(Todo).filter(Todo.user_id == current_user.id).all()


@router.post("/", response_model=TodoResponse, status_code=status.HTTP_201_CREATED)
def create_todo(
    payload: TodoCreate,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    todo = Todo(**payload.model_dump(), user_id=current_user.id)
    db.add(todo)
    db.commit()
    db.refresh(todo)
    return todo


@router.put("/{todo_id}", response_model=TodoResponse)
def update_todo(
    todo_id: str,
    payload: TodoUpdate,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    todo = db.query(Todo).filter(Todo.id == todo_id, Todo.user_id == current_user.id).first()
    if not todo:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
    for field, value in payload.model_dump(exclude_unset=True).items():
        setattr(todo, field, value)
    db.commit()
    db.refresh(todo)
    return todo


@router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_todo(
    todo_id: str,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    todo = db.query(Todo).filter(Todo.id == todo_id, Todo.user_id == current_user.id).first()
    if not todo:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
    db.delete(todo)
    db.commit()

Notice .filter(Todo.id == todo_id, Todo.user_id == current_user.id). Without scoping to the current user, any authenticated user could read or delete anyone else's todos. Always scope queries to the authenticated user.

You will also need to add user_id to the Todo model and run a new migration:

# In models.py, add to the Todo class:
user_id = Column(String, ForeignKey("users.id"), nullable=False)
alembic revision --autogenerate -m "add user_id to todos"
alembic upgrade head

Updating the React Frontend

The frontend needs to handle login and attach the token to every request. Update src/services/ApiService.ts:

import axios from 'axios'
import { CreateTodoPayload, Todo, UpdateTodoPayload } from '../types'

const API_BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'

const client = axios.create({ baseURL: API_BASE_URL })

client.interceptors.request.use((config) => {
  const token = localStorage.getItem('access_token')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

export const login = async (username: string, password: string): Promise<void> => {
  const params = new URLSearchParams()
  params.append('username', username)
  params.append('password', password)

  const response = await client.post<{ access_token: string; token_type: string }>(
    '/auth/token',
    params,
    { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
  )
  localStorage.setItem('access_token', response.data.access_token)
}

export const logout = (): void => {
  localStorage.removeItem('access_token')
}

export const getAllTodos = async (): Promise<Todo[]> => {
  const response = await client.get<Todo[]>('/todos/')
  return response.data
}

// ... rest of the functions unchanged

The /auth/token endpoint expects application/x-www-form-urlencoded because that is what the OAuth2 password flow spec requires. This is why we use URLSearchParams rather than a JSON body.

A note on localStorage. Storing tokens in localStorage exposes them to any JavaScript running on the page, including scripts injected via XSS. The safer option is httpOnly cookies, which JavaScript cannot read at all. For a production app, cookies with httpOnly, Secure, and SameSite=Strict are the right call. We are using localStorage here to keep the frontend simple, but understand the tradeoff before shipping it.


Testing with Swagger

Start the API and open http://localhost:8000/docs. There is an Authorize button in the top right. FastAPI generates this automatically because of OAuth2PasswordBearer. Click it, enter your credentials, and every subsequent request from the Swagger UI will include the token.


The Real Tradeoffs

This setup works and is a significant improvement over no auth. But there are things you should understand before shipping it.

No token revocation. Once a JWT is issued it is valid until expiry. There is no built-in way to invalidate a specific token if it is compromised. Short expiry times and a token blocklist in Redis are the common mitigations. Neither comes free.

Single server assumption. HS256 uses a shared secret. All API instances need the same SECRET_KEY. This is easy to manage with environment variables, but it is worth knowing.

Password management is your problem. Registration, password reset, email verification, rate limiting on login attempts, account lockout policies. None of that comes with this setup. You have to build or borrow every piece.

When to reach for an SSO provider. If your users already have accounts somewhere else, or if you need MFA, social login, or fine-grained permissions, rolling your own auth gets expensive fast. The next two posts in this series walk through Keycloak and Authentik, both of which replace everything we just built with a proper identity provider.


Up Next

We have a properly secured API with hashed passwords, JWT tokens, and per-user data isolation. The next post covers Dockerizing the full stack, handling environment variables correctly, and what changes when you move from SQLite to a real database for production.