Handbooks / FastAPI / Chapter 6
Authentication and Security
7 pages · ~5 min✓ Reviewed
Builds on Middleware and Exception Handlers. Next up: Database Migrations with Alembic.
Security Mental Model
1. Who You Are vs What You May Do
| Asks | Failure | |
|---|---|---|
| Authentication | who are you? | 401 Unauthorized |
| Authorization | what may you do? | 403 Forbidden |
2. OAuth2 Password Flow
- POST /tokenusername + password as form data
- Access tokensigned JWT returned
- Every requestAuthorization: Bearer <token>
- Server stays stateless: the signed JWT carries the identity
- No session lookup per request
3. Security Classes in FastAPI
OAuth2PasswordBearer,APIKeyHeaderare declared as dependencies- They also add the Authorize button to the
/docspage
python
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") api_key = APIKeyHeader(name="X-API-Key") @app.get("/me") async def me(token: str = Depends(oauth2_scheme)): ...
Password flow = first-party only
Use it only for clients you fully trust. Third-party apps should use authorization code flow with PKCE.
Password Hashing
1. What to Store
- Never store plaintext or fast hashes (MD5, SHA-256 alone)
- Use a slow, salted algorithm: bcrypt or argon2
- Hashing is one-way: you never decrypt
- Login: re-hash the attempt, verify against the stored hash
- Salt is random per password, embedded in the hash string
- Same password twice gives two different hashes
- Login attemptplain password
- Re-hashsalt from stored hash
- Verifyconstant-time
- Match?yes: issue token
2. pwdlib Example
PasswordHash.recommended()gives an argon2 hasherph.hash(pw)at signup;ph.verify(pw, hash)at login
python
from pwdlib import PasswordHash ph = PasswordHash.recommended() hash = ph.hash(pw) # store this ok = ph.verify(pw, hash) # True / False
salt and parameters live inside the hash string
3. Mistakes
JWT Access Tokens and the Login Endpoint
1. What a JWT is
- Headerbase64url
- Payloadbase64url
- Signaturebase64url
- Payload is readable by anyone: signed, not encrypted
- sub user id, exp expiry (Unix time), iat issued at
- Scopes go in a custom
scopeclaim
2. Create, decode, log in
encodesigns;decodechecks the signature andexp/tokentakes form data and returns a bearer token
python
token = jwt.encode(
{"sub": user, "exp": now + timedelta(minutes=30)},
SECRET_KEY, algorithm="HS256")
data = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])python
@app.post("/token") def login(form: OAuth2PasswordRequestForm = Depends()): user = verify_user(form) return {"access_token": make_jwt(user), "token_type": "bearer"}
3. Common mistakes
Security Dependencies and Scopes
1. The Dependency Chain
- 1oauth2_schemetoken or 401
- 2get_current_userdecode, load
- 3get_current_active_userdisabled: 400/403
- 4RouteDepends(...)
OAuth2PasswordBearerreads the bearer token fromAuthorization; 401 if missing- Bad token: raise
HTTPException(401)withWWW-Authenticate: Bearer - Routes only declare
Depends(get_current_active_user)
python
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") async def get_current_user(token: str = Depends(oauth2_scheme)): user = load_user(decode(token)) if not user: raise HTTPException(401, headers={"WWW-Authenticate": "Bearer"}) return user
2. Scopes
- Declare scopes on the scheme; guard routes with Security
SecurityScopes.scopes= what the route needs; compare to the token'sscopeclaim
python
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", scopes={"items:read": "Read items"}) async def get_current_user(sec: SecurityScopes, token: str = Depends(oauth2_scheme)): granted = decode(token).get("scope", "").split() if any(s not in granted for s in sec.scopes): raise HTTPException(403, "Not enough permissions") @app.get("/items") async def items(u = Security(get_current_user, scopes=["items:read"])): ...
CORS Middleware and API Keys
1. CORS and Setup
- CORS is a browser rule: other-origin responses are unreadable unless the server allows it
- Origin =
scheme+host+port - Not protection against curl or other servers
python
app.add_middleware(
CORSMiddleware,
allow_origins=['https://app.example.com'],
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'])- PreflightOPTIONS + Access-Control-Request-*
- Middlewareanswers automatically
- Real requestJSON, PUT/DELETE, custom headers
2. Wildcard Trap
allow_origins=['*']+allow_credentials=True: never combine- Starlette mirrors the caller's Origin on preflight and responses
- Any site can then make credentialed calls
- Fix: list explicit origins
3. API Keys
Security Cheat Sheet
1. Login Flow and Status Codes
- POST /tokenform data
- Verify hash
- Issue JWT
- Bearer headerAuthorization
- Depends(oauth2_scheme)decode → user
| Code | When | Header |
|---|---|---|
| 401 | bad or missing credentials | WWW-Authenticate: Bearer |
| 403 | authenticated, missing scope | none |
2. Hash and JWT
- Hash with argon2 (pwdlib) or bcrypt, never MD5 or SHA alone
- Set exp on every token
- Pin
algorithms=[...]on decode - Keep SECRET_KEY in env, not in code
bash
openssl rand -hex 32generate SECRET_KEY
3. Dependencies and CORS
Check yourself
A teammate stores the user's password in the JWT payload because the token is signed. What is wrong?
- Signed is not encrypted: anyone can base64-decode the payload.
- Keep only
sub,expand scopes in it.
The app uses allow_origins=['*'] with allow_credentials=True. What is the risk?
- Starlette mirrors any caller's Origin, so any site can make credentialed calls.
- List explicit origins instead.
A route uses Depends(get_current_user), and that dependency checks SecurityScopes. A token without items:read gets 200. Why?
Dependspasses no scopes, soSecurityScopes.scopesis empty and the check always passes.- Use
Security(get_current_user, scopes=['items:read']).