FastAPI Auth You Can Reuse: JWT Login, Dependency-Based Permissions, and “Good Errors” (Hands-On)
FastAPI makes it easy to ship APIs fast—but authentication and authorization can get messy if you sprinkle token parsing, user lookup, and permission checks across every route.
In this hands-on guide, you’ll build a clean, reusable pattern for:
- Issuing JWT access tokens with
/auth/login - Protecting routes with a single dependency (
get_current_user) - Adding role/permission checks with
require_role(...) - Returning consistent, helpful errors (without leaking secrets)
This is a great fit for junior/mid developers because it scales from “one service” to “many endpoints” without turning into spaghetti.
1) Project Setup
Install dependencies:
python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate pip install fastapi uvicorn "python-jose[cryptography]" passlib[bcrypt] pydantic
Run the app (we’ll create main.py next):
uvicorn main:app --reload
2) A Minimal User Store (In-Memory) + Password Hashing
In production you’d use a database, but the patterns are identical. We’ll keep a small in-memory “DB” so you can run this immediately.
# main.py from datetime import datetime, timedelta, timezone from typing import Callable, Dict, Optional from fastapi import Depends, FastAPI, HTTPException, Request, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from jose import JWTError, jwt from passlib.context import CryptContext from pydantic import BaseModel app = FastAPI() # Password hashing pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") def hash_password(raw: str) -> str: return pwd_context.hash(raw) def verify_password(raw: str, hashed: str) -> bool: return pwd_context.verify(raw, hashed) # "Database" # NOTE: store hashed passwords, never plain text. USERS: Dict[str, Dict] = { "alice": {"id": 1, "username": "alice", "password_hash": hash_password("alicepw"), "role": "admin"}, "bob": {"id": 2, "username": "bob", "password_hash": hash_password("bobpw"), "role": "user"}, }
Two demo users exist:
alice / alicepw(admin)bob / bobpw(user)
3) JWT Basics: Sign, Expire, Validate
A JWT is just a signed payload. We’ll include:
sub: subject (username)role: authorization hintexp: expiration (required)
Add JWT config + token helpers:
# main.py (continue) # JWT config (use environment variables in real apps!) JWT_SECRET = "change-me-in-prod" JWT_ALG = "HS256" ACCESS_TOKEN_MINUTES = 30 class TokenOut(BaseModel): access_token: str token_type: str = "bearer" def create_access_token(*, sub: str, role: str, expires_minutes: int = ACCESS_TOKEN_MINUTES) -> str: now = datetime.now(timezone.utc) exp = now + timedelta(minutes=expires_minutes) payload = { "sub": sub, "role": role, "iat": int(now.timestamp()), "exp": exp, } return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG)
4) Login Endpoint (OAuth2 Password Flow Style)
FastAPI’s OAuth2PasswordRequestForm gives you the classic username + password form fields. This works well for simple APIs and integrates with the interactive docs.
# main.py (continue) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") def authenticate_user(username: str, password: str) -> Optional[Dict]: user = USERS.get(username) if not user: return None if not verify_password(password, user["password_hash"]): return None return user @app.post("/auth/login", response_model=TokenOut) def login(form: OAuth2PasswordRequestForm = Depends()): user = authenticate_user(form.username, form.password) if not user: # Avoid revealing whether username or password was wrong. raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials", headers={"WWW-Authenticate": "Bearer"}, ) token = create_access_token(sub=user["username"], role=user["role"]) return TokenOut(access_token=token)
Test it in the Swagger UI (/docs) or with curl:
curl -X POST "http://127.0.0.1:8000/auth/login" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=alice&password=alicepw"
5) A Clean “Current User” Dependency
This is the core pattern: one dependency that:
- reads the bearer token
- verifies signature + expiry
- loads the user
- returns a typed “current user” object
# main.py (continue) class CurrentUser(BaseModel): id: int username: str role: str def decode_token(token: str) -> Dict: try: return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG]) except JWTError: # JWTError covers invalid signature, expired token, malformed token, etc. raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token", headers={"WWW-Authenticate": "Bearer"}, ) def get_current_user(token: str = Depends(oauth2_scheme)) -> CurrentUser: payload = decode_token(token) username = payload.get("sub") if not username: raise HTTPException(status_code=401, detail="Token missing subject") user = USERS.get(username) if not user: # Token may be valid but user was deleted/disabled. raise HTTPException(status_code=401, detail="User not found") return CurrentUser(id=user["id"], username=user["username"], role=user["role"])
Now you can protect any route with Depends(get_current_user).
@app.get("/me") def read_me(current: CurrentUser = Depends(get_current_user)): return {"id": current.id, "username": current.username, "role": current.role}
6) Authorization: Reusable Role Checks
Authentication answers “who are you?”. Authorization answers “what can you do?”. A nice pattern in FastAPI is a dependency factory: a function that returns a dependency function.
# main.py (continue) def require_role(required: str) -> Callable: def checker(current: CurrentUser = Depends(get_current_user)) -> CurrentUser: if current.role != required: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires role: {required}", ) return current return checker @app.get("/admin/stats") def admin_stats(current: CurrentUser = Depends(require_role("admin"))): return {"status": "ok", "message": f"Hello admin {current.username}"}
Try calling /admin/stats with Bob’s token and you’ll get a clear 403.
7) Consistent Error Responses (Without Leaking Details)
When you build real APIs, error consistency matters. Frontends and other services depend on predictable formats. Let’s add a simple global exception handler for HTTPException to wrap responses with a common shape.
# main.py (continue) from fastapi.responses import JSONResponse @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException): # Keep it simple: consistent shape with path and status. return JSONResponse( status_code=exc.status_code, content={ "error": { "message": exc.detail, "status": exc.status_code, "path": str(request.url.path), } }, headers=getattr(exc, "headers", None), )
Now your API errors look like:
{ "error": { "message": "Invalid or expired token", "status": 401, "path": "/me" } }
That’s much easier to consume than a dozen different ad-hoc error formats.
8) Common Pitfalls (And Quick Fixes)
-
Using a hard-coded secret in production
Move
JWT_SECRETto an environment variable and rotate it when needed. Consider key IDs (kid) if you do more advanced setups. -
Forgetting token expiration
Always include
exp. Short-lived access tokens reduce blast radius. -
Overstuffing the JWT payload
Put only what you need. Claims should be stable, small, and safe to expose to the client (JWT contents are base64-encoded, not encrypted).
-
Returning “user not found” vs “bad password”
For login, keep errors generic (
Invalid credentials) so you don’t help attackers enumerate accounts.
9) A Quick End-to-End Test With Curl
1) Login as Bob:
TOKEN=$(curl -s -X POST "http://127.0.0.1:8000/auth/login" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=bob&password=bobpw" | python -c "import sys,json; print(json.load(sys.stdin)['access_token'])") echo $TOKEN
2) Call /me:
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/me
3) Attempt admin endpoint (should fail with 403):
curl -i -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/admin/stats
10) Where to Take This Next
Once you’re comfortable with this pattern, the next production-ish upgrades are straightforward:
- Add refresh tokens (long-lived refresh, short-lived access)
- Store users in a DB and add “disabled” / “deleted” flags
- Implement permissions (e.g.,
posts:write) instead of simple roles - Add audit logging for auth failures
- Use asymmetric signing (RS256) if multiple services need to verify tokens without sharing the signing key
The key takeaway: use FastAPI dependencies to centralize authentication and authorization. Your endpoints stay small and readable, and your security logic becomes reusable, testable, and consistent.
Leave a Reply