OAuth2 Password Flow
OAuth2 is a large specification. Most of it is about delegated access - "let this third-party app act on your behalf." For a backend that owns its own users and just needs to authenticate them with a username and password, you only need a small slice of OAuth2 called the password flow.
FastAPI ships with built-in support for it, which is why you see OAuth2PasswordBearer and OAuth2PasswordRequestForm in almost every FastAPI tutorial. It is not that OAuth2 is required for login - it is that adopting its conventions makes your API speak the same language as the Swagger UI, the docs system, and a lot of client libraries.
The shape of the flow
Client Server
| |
| POST /auth/token |
| username=alice |
| password=hunter2 |
| -------------------------> |
| | verify credentials
| | build access_token
| { access_token, type } |
| <------------------------- |
| |
| GET /me |
| Authorization: Bearer ... |
| -------------------------> |
| | decode token, find user
| { id, email, ... } |
| <------------------------- |Two endpoints. One issues tokens, the other reads them.
Why a special form, not JSON?
OAuth2 mandates that the token endpoint accept application/x-www-form-urlencoded, the same encoding HTML forms use. That decision predates JSON being everywhere. FastAPI follows the spec so that standard tools just work, which is why login uses OAuth2PasswordRequestForm instead of a Pydantic model.
You don't need to design that form yourself - the dependency unpacks the body for you.
The token endpoint
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from .database import get_db
from .models import User
from .security import verify_password
SECRET_KEY = "change-me-and-keep-me-secret"
ALGORITHM = "HS256"
ACCESS_TOKEN_MINUTES = 30
router = APIRouter(prefix="/auth", tags=["auth"])
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_MINUTES)
payload = {"sub": subject, "exp": expire}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
@router.post("/token")
def login(
form: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.email == form.username).first()
if user is None or not verify_password(form.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
token = create_access_token(subject=user.email)
return {"access_token": token, "token_type": "bearer"}A few details worth pausing on:
form.usernameholds whatever was sent in theusernamefield. We're storing emails as the identifier, so we look up by email but the field name stays asusernamebecause the spec says so.- The error message is intentionally vague. "Incorrect email or password" gives away less than "no such user" - an attacker should not be able to enumerate accounts by trying random emails.
- The
WWW-Authenticateheader is part of the OAuth2 convention for401responses on a Bearer-protected endpoint.
The bearer scheme
For other endpoints to read the token, FastAPI provides a dependency that pulls it out of the Authorization: Bearer <token> header.
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token")The tokenUrl is the path the docs UI should call to obtain a token when a developer clicks the "Authorize" button. It is not used at runtime for any redirection - it is purely metadata for Swagger.
The "current user" dependency
This is the piece almost every protected route depends on.
from jwt import InvalidTokenError
def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> User:
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
email = payload.get("sub")
if email is None:
raise credentials_error
except InvalidTokenError:
raise credentials_error
user = db.query(User).filter(User.email == email).first()
if user is None:
raise credentials_error
return userUse it from any route that needs to know who is calling:
@app.get("/me", response_model=UserOut)
def read_me(current: User = Depends(get_current_user)):
return currentTrying it from Swagger
Open /docs. You will see a small padlock icon on protected routes and an Authorize button at the top right. Click it, type a real email and password, and the docs page will send the password-flow request for you and remember the returned token. Every subsequent "Try it out" call will include the Authorization header automatically.
This is one of the genuinely lovely things about FastAPI. You get a working login UI for free.
Where the password flow stops being a good idea
The OAuth2 spec itself now discourages the password flow for third-party clients - apps you don't own should never see your users' passwords. The flow is still fine when:
| Situation | Password flow OK? |
|---|---|
| Your own first-party frontend talking to your own backend | Yes |
| A mobile app that you also ship | Yes |
| A CLI tool used by your own team | Yes |
| A random third-party integration | No - use authorization code flow |
| Service-to-service traffic | No - use client credentials or API keys |
If you ever find yourself building a public OAuth2 provider, you will want to graduate to the authorization code flow with PKCE. For an app where you control both ends, the password flow is honest and simple.
The handoff
We've built the issuing side and a basic decoder. The next doc digs into JWT specifically - why those payloads look the way they do, what exp and sub actually mean, and how refresh tokens fit in so users don't have to log in every thirty minutes.
How is this guide?
Last updated on
