Almost every API needs to know who is calling, and this is FastAPI's documented pattern, integrated with /docs.
FastAPI's security utilities are dependencies that also describe themselves in OpenAPI, so /docs gets an Authorize button. The common setup is the OAuth2 password flow with JWT bearer tokens:
- The client POSTs form fields
usernameandpasswordto/token;OAuth2PasswordRequestFormparses them (form parsing needspython-multipart, included infastapi[standard]). - You verify the password against a stored hash, never plain text. FastAPI's docs use
pwdlibwith Argon2 viaPasswordHash.recommended(). - You return
{"access_token": token, "token_type": "bearer"}, where the token is a JWT signed with a server secret using PyJWT. - Later requests send
Authorization: Bearer <token>.OAuth2PasswordBearer(tokenUrl="token")is a dependency that extracts the token, or answers 401 if it's missing. - Your
current_userdependency decodes it (jwt.decodeverifies the signature and theexpclaim), loads the user, and raises 401 withWWW-Authenticate: Beareron any failure.
Endpoints then just declare user: Annotated[User, Depends(current_user)]. Layer extra checks as further dependencies: one that rejects disabled accounts, another that requires an admin role.
A JWT is signed, not encrypted: anyone holding it can read the claims, so put only identifiers in it. Keep access tokens short-lived (15 to 60 minutes), keep the secret in settings, and always pass algorithms=[...] to decode so the token can't choose its own algorithm. Serve it only over HTTPS. If users sign in through Google or a corporate identity provider, you validate that provider's tokens instead, but the dependency shape stays the same.
Example
from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pwdlib import PasswordHash
SECRET_KEY = "change-me: load a 32+ byte random secret from settings"
ALGORITHM = "HS256"
hasher = PasswordHash.recommended()
users = {"ann": hasher.hash("s3cret")} # username -> password hash
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI()
@app.post("/token")
def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
hashed = users.get(form.username)
if not hashed or not hasher.verify(form.password, hashed):
raise HTTPException(status_code=401, detail="Incorrect username or password")
exp = datetime.now(timezone.utc) + timedelta(minutes=30)
token = jwt.encode({"sub": form.username, "exp": exp}, SECRET_KEY, algorithm=ALGORITHM)
return {"access_token": token, "token_type": "bearer"}
def current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> str:
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])["sub"]
except jwt.InvalidTokenError:
raise HTTPException(401, "Invalid token", headers={"WWW-Authenticate": "Bearer"})
@app.get("/me")
def me(user: Annotated[str, Depends(current_user)]):
return {"user": user}Remember
- /token checks a password hash and returns a signed, expiring JWT
- OAuth2PasswordBearer pulls the bearer token from the Authorization header
- A current_user dependency verifies the token; endpoints just depend on it
- JWTs are readable by anyone: sign, expire and keep secrets out of claims
Watch out for
- Calling jwt.decode without algorithms=[...], or with verification options turned off.
- Storing plain or fast-hashed (MD5, SHA-256) passwords instead of Argon2 or bcrypt.