- Published on
Securing FastAPI with OAuth2 and JWT


- Name
- Chad Wilson
- @NetPenguins
Series
Full Stack Todo App
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
- OWASP Password Storage Cheat Sheet. Covers why bcrypt/Argon2 and not MD5/SHA256, and how work factors work.
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:
- User posts credentials to
/auth/token - We verify the password hash against what is stored in the database
- We issue a signed JWT with an expiry
- The client stores the token and sends it in the
Authorization: Bearer <token>header on protected routes - 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
- python-jose handles JWT encoding and decoding
- passlib handles password hashing with bcrypt
- python-multipart is required by FastAPI to parse the OAuth2 form submission
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.