FastAPI: Production-Friendly Auth with JWT, Role Checks, and Route Dependencies (Hands-On)
FastAPI makes it easy to ship APIs quickly—but “real” apps usually need authentication, authorization, and a clean way to protect routes. In this hands-on guide, you’ll build a small, production-friendly auth layer using JWT access tokens, password hashing, role checks, and dependency-based route protection. Everything is practical and copy/paste-able for junior/mid developers.
You’ll implement:
- Password hashing with
passlib - JWT creation + verification with
python-jose - A login route that returns a Bearer token
- A reusable
get_current_userdependency - Role-based access control (RBAC) with a simple
require_role()helper - A tiny in-memory “DB” you can later replace with SQLAlchemy/SQLModel
1) Install dependencies
Create a venv and install the packages:
pip install fastapi uvicorn[standard] python-jose[cryptography] passlib[bcrypt] pydantic
Run the server with:
uvicorn main:app --reload
2) Define config and security utilities
Create main.py and start with settings and helper functions. Use a strong secret in production (environment variable, not hard-coded).
from datetime import datetime, timedelta, timezone from typing import Optional, Dict from fastapi import FastAPI, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from jose import jwt, JWTError from passlib.context import CryptContext from pydantic import BaseModel app = FastAPI() # ✅ In production, read these from env vars / secrets manager JWT_SECRET = "change-me-in-prod" # DO NOT ship this value JWT_ALG = "HS256" ACCESS_TOKEN_TTL_MIN = 30 pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") def hash_password(password: str) -> str: return pwd_context.hash(password) def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) def create_access_token(*, sub: str, role: str, ttl_minutes: int = ACCESS_TOKEN_TTL_MIN) -> str: now = datetime.now(timezone.utc) payload = { "sub": sub, # subject (usually user id or username) "role": role, # role claim "iat": int(now.timestamp()), "exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()), } return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG) def decode_token(token: str) -> dict: return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG])
Notes:
subis the standard JWT “subject”. Use a stable identifier (user id) if you have one.expis required for time-limited access tokens. Keep it short (15–60 minutes).- For refresh tokens, you’d typically issue a second token with a longer TTL stored server-side (out of scope here).
3) Add simple user models and a fake “database”
To keep things focused, we’ll store users in a dict. You can swap these functions with real DB queries later.
class User(BaseModel): username: str role: str class UserInDB(User): hashed_password: str class TokenResponse(BaseModel): access_token: str token_type: str = "bearer" # Fake in-memory database fake_users: Dict[str, UserInDB] = { "alice": UserInDB( username="alice", role="admin", hashed_password=hash_password("alicepass"), ), "bob": UserInDB( username="bob", role="user", hashed_password=hash_password("bobpass"), ), } def get_user(username: str) -> Optional[UserInDB]: return fake_users.get(username)
4) Build the login endpoint (OAuth2 Password Flow)
FastAPI’s OAuth2PasswordRequestForm expects form-encoded fields named username and password. This is compatible with many tools and Swagger UI.
@app.post("/auth/login", response_model=TokenResponse) def login(form: OAuth2PasswordRequestForm = Depends()): user = get_user(form.username) if not user or not verify_password(form.password, user.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password", headers={"WWW-Authenticate": "Bearer"}, ) token = create_access_token(sub=user.username, role=user.role) return TokenResponse(access_token=token)
Try it in Swagger:
- Open
/docs - POST
/auth/loginwithalice/alicepass - Copy
access_tokenand use it asAuthorization: Bearer ...
5) Protect routes with a “current user” dependency
This is the key pattern: a dependency that reads the Bearer token, verifies it, and returns a user model. Routes just depend on it.
def get_current_user(token: str = Depends(oauth2_scheme)) -> User: try: payload = decode_token(token) username = payload.get("sub") role = payload.get("role") if not username or not role: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload", headers={"WWW-Authenticate": "Bearer"}, ) except JWTError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token", headers={"WWW-Authenticate": "Bearer"}, ) # Optional: check that the user still exists (and hasn't been disabled) user_in_db = get_user(username) if not user_in_db: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found", headers={"WWW-Authenticate": "Bearer"}, ) return User(username=user_in_db.username, role=user_in_db.role)
Now add a route that requires authentication:
@app.get("/me") def read_me(current_user: User = Depends(get_current_user)): return {"username": current_user.username, "role": current_user.role}
6) Add role-based authorization with a dependency factory
Authentication answers “who are you?” Authorization answers “are you allowed to do this?” A clean FastAPI way is a dependency factory that returns a dependency.
def require_role(*allowed_roles: str): def _checker(current_user: User = Depends(get_current_user)) -> User: if current_user.role not in allowed_roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires role: {', '.join(allowed_roles)}", ) return current_user return _checker
Create routes for user-only and admin-only access:
@app.get("/reports") def read_reports(current_user: User = Depends(require_role("admin"))): # Only admins can see this return {"ok": True, "data": ["revenue.csv", "churn.xlsx"], "viewer": current_user.username} @app.get("/projects") def read_projects(current_user: User = Depends(require_role("admin", "user"))): # Users and admins can see this return {"projects": ["alpha", "beta"], "viewer": current_user.username}
7) Test it with curl
Login and store the token:
# Login (form-encoded) TOKEN=$(curl -s -X POST "http://127.0.0.1:8000/auth/login" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=alice&password=alicepass" | python -c "import sys, json; print(json.load(sys.stdin)['access_token'])") echo $TOKEN
Call a protected route:
curl -s "http://127.0.0.1:8000/me" \ -H "Authorization: Bearer $TOKEN"
Try an admin-only route with a non-admin token:
# Login as bob (role=user) TOKEN2=$(curl -s -X POST "http://127.0.0.1:8000/auth/login" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=bob&password=bobpass" | python -c "import sys, json; print(json.load(sys.stdin)['access_token'])") # Should return 403 curl -i "http://127.0.0.1:8000/reports" \ -H "Authorization: Bearer $TOKEN2"
8) Practical hardening tips (what juniors often miss)
-
Never hard-code secrets in git. Put
JWT_SECRETin env vars and rotate it if leaked. -
Use short-lived access tokens. 15–60 minutes is common. For a smooth UX, add refresh tokens later.
-
Verify users against the database. Token validation alone can’t tell if the user was disabled after token issuance.
-
Don’t store passwords; store hashes.
bcryptis a solid default (as used bypasslib). -
Return 401 vs 403 correctly.
401= not authenticated / invalid token.403= authenticated but not allowed. -
Keep claims minimal. Put only what you need in the JWT (e.g.,
sub,role). Avoid PII. -
Consider token revocation strategy. JWTs are stateless; to revoke them early, you need a denylist, token versioning, or short TTL + refresh.
9) Where to go next
If you want to evolve this into a production setup, the next steps are straightforward:
-
Replace
fake_userswith a real DB table (SQLAlchemy/SQLModel), includingis_activeandrolecolumns. -
Add refresh tokens stored server-side (DB/Redis) so you can revoke sessions.
-
Add “scopes” or permissions (fine-grained) instead of roles only.
-
Write tests for auth flows using
pytestand FastAPI’sTestClient.
You now have a clean, dependency-driven auth pattern that scales: routes declare their requirements, auth logic stays centralized, and adding new roles/permissions doesn’t turn your codebase into a pile of if statements.
Leave a Reply