FastAPI: Production-Friendly Auth with JWT, Role Checks, and Route Dependencies (Hands-On)

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_user dependency
  • 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:

  • sub is the standard JWT “subject”. Use a stable identifier (user id) if you have one.
  • exp is 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/login with alice/alicepass
  • Copy access_token and use it as Authorization: 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_SECRET in 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. bcrypt is a solid default (as used by passlib).

  • 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_users with a real DB table (SQLAlchemy/SQLModel), including is_active and role columns.

  • 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 pytest and FastAPI’s TestClient.

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

Your email address will not be published. Required fields are marked *