FastAPI Depends() Plumbing for Auth & RBAC (get_current_user)
What? (Concept Overview)
FastAPI’s Depends() plumbing composes auth as a function argument rather than middleware: an OAuth2PasswordBearer URL-declares the token endpoint, a get_current_user dependency parses the Authorization: Bearer … header, decodes the JWT, and returns the authenticated user to the handler. Every protected route just adds Depends(get_current_active_user) and the security policy is enforced uniformly.
Project Context
The FCA Support Agent’s app/api/deps.py exposes get_current_user and get_current_active_user. RBAC scopes (read:accounts, write:messages, etc.) are embedded in the JWT payload at issue time (app/api/routes/auth.py) and validated by SecurityService.verify_token — single source of truth across /api/v1/messages and /api/v1/admin routers.
How? (Quick Reference Blocks)
3.1 OAuth2Bearer Token URL Declaration
# app/api/deps.py
from fastapi.security import OAuth2PasswordBearer
# `tokenUrl` is used by the OpenAPI docs to render the "Authorize" button.
# Must match the actual login endpoint in app/api/routes/auth.py.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")3.2 get_current_user — Parse → Verify → Lookup
# app/api/deps.py
from fastapi import Depends, HTTPException, status
from jose import JWTError, jwt
from app.services.security_service import SecurityService, oauth2_scheme
async def get_current_user(
token: str = Depends(oauth2_scheme),
) -> "Customer":
payload = SecurityService().verify_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid token payload")
# Lazy-fetch the user — services own the DB session lifecycle.
async with CustomerService() as svc:
user = await svc.repo.get_by_id(user_id)
if user is None:
raise HTTPException(status_code=401, detail="User not found")
return user3.3 get_current_active_user — Active-Account Gate
# app/api/deps.py
async def get_current_active_user(
current_user = Depends(get_current_user),
) -> "Customer":
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user3.4 RBAC Scope Enforcement
# app/api/deps.py (illustrative Security dependency factory)
from fastapi import Security
from typing import List
def require_scopes(required: List[str]):
async def _check(
token: str = Depends(oauth2_scheme),
):
payload = SecurityService().verify_token(token)
scopes = (payload or {}).get("scopes", [])
if not all(s in scopes for s in required):
raise HTTPException(
status_code=403,
detail=f"Missing required scopes: {required}",
)
return payload
return _check
# Usage in a router:
@router.post("/admin/scary-action", dependencies=[Security(require_scopes(["admin"]))])
async def scary_action(): ...Why? (Parameter Breakdown
OAuth2PasswordBearerinstead of raw header parsing — Generates the OpenAPI “Authorize” UI for free; clients can paste a Bearer token and exercise protected endpoints from the docs page.- Returning the Customer (or None) instead of the token string — Callers receive a typed object.
current_user.idis enough; nopayload["sub"]ceremony per handler. - Lazy DB lookup inside
get_current_user— Verifying the JWT signature is fast (HMAC); verifying the user is still active is the DB round-trip. The two-stage pipeline mirrors Express.js / Spring Security patterns. WWW-Authenticate: Bearerheader on 401** — RFC 6750 says clients should be told how to authenticate. Without it, clients can’t distinguish “no token” from “expired token”.Security(...)vsDepends(...)for RBAC —Security()is the documented way to attach security-scoped dependencies; it surfaces them correctly in OpenAPI undersecuritySchemes.- Scopes as
List[str]in the JWT payload — Allow coarse-grained authorisation: “admin”, “operator”, “user”. A scope-upgrade is a token re-issue with no code/deploy needed.
Common Pitfalls
- Reading
payload["sub"]without type-checking. A legacy token issued before the schema change may havesubas an integer; comparison breaks silently. Cast + validate:user_id = int(payload["sub"])inside atry/except (KeyError, TypeError, ValueError). - Not including the lookup error in
WWW-Authenticate— RFC 6750 expectserror="invalid_token"(anderror_description=) so OAuth-aware clients can show “session expired, please log in again” instead of “auth failed”.
Real-World Interview Prep
Q1: Why split get_current_user from get_current_active_user?
A: Two-stage validation lets you opt out at the right level. get_current_user enforces “the token is valid and the user exists” (every protected endpoint needs this). get_current_active_user adds “the user isn’t disabled” — useful for routes that need it (e.g., /api/v1/messages) but unnecessary for the token-refresh endpoint itself. Without the split, every disabled user is locked out of token refresh → forced logout loop.
Q2: How do you invalidate a token issued to a former employee without rotating the JWT secret?
A: Use a token-blacklist (Redis SET of revoked jti claims) checked inside get_current_user. Pattern: at logout, push the JWT’s jti into revoked_tokens with TTL = remaining token lifetime. get_current_user does if payload["jti"] in redis.smembers("revoked_tokens"): raise 401. Alternative: short access tokens (e.g., 5 min) with refresh tokens that DO check the blacklist; rotation frequency reduces blast radius.
Q3: What does OAuth2PasswordBearer actually do behind the scenes?
A: Just declares a tokenUrl for OpenAPI. It does NOT enforce any auth. The enforcement is whatever you put in your dependency function. Common misconception: people think installing the scheme on a route magically rejects unauthenticated calls. It doesn’t — you still need Depends(oauth2_scheme) somewhere to extract the bearer token.
Top-to-Bottom Code Walkthrough (app/api/deps.py)
This file holds the plumbing that every protected endpoint reuses. FastAPI’s Depends() runs these once per request; if they raise, the endpoint is never called.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
- A
Security()dependency that tells FastAPI: “theAuthorization: Bearer <token>header on every request is parsed here. The token-endpoint is/api/v1/auth/login(used for OpenAPI docs only).” - It exposes a
token: str = Depends(oauth2_scheme)parameter insideget_current_user.
get_current_user(token, db) -> Customer
- Decode the JWT:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm]).- The
algorithms=[...]argument prevents algorithm-confusion attacks (e.g., a token forged withalg=none). - If the signature is invalid OR the token is expired,
jwt.decoderaisesJWTErrorwhich propagates as a401.
- The
- Lookup the user:
user_id = int(payload.get("sub"))—subis the standard JWT “subject” claim, set to the Customer’s primary key at login time.user = await db.execute(select(Customer).where(Customer.id == user_id)). - Authorization hallucination guard:
security_service.verify_scopes(payload.get("scopes", []), required_scopes)— checks that each scope used inDepends(..., scopes=["write:messages"])is present in the JWT payload. This is RBAC at the dependency level, no per-endpoint if/else.
get_current_active_user(user)
- Tiny wrapper:
if not user.is_active: raise HTTPException(400, "Inactive user"). Some endpoints (like/health) need the user but not the active check; this keeps the choice with the endpoint, not the dependency.
How endpoints consume these
@router.post("/messages")
async def send_message(
body: MessageBody,
current_user: Customer = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
if body.customer_id != current_user.customer_id:
raise HTTPException(403, "IDOR") # IDOR guardThe endpoint’s only authentication concern is current_user.customer_id != body.customer_id — that’s the IDOR check.
Common Pitfalls
Reading the JWT with jwt.decode(token, SECRET_KEY) (no algorithms=) in 2024+. Always pass algorithms=[settings.jwt_algorithm] — without it, an attacker can submit a forged token signed with alg=none and bypass everything.
Storing the password hashed in the JWT payload — tokens are readable on the client; secrets belong on the server.
Forgetting to mark the token type="access" lets a refresh token act as an access token. Always include a type claim.
Real-World Interview Prep
Q1: Why is current_user: Customer = Depends(get_current_active_user) better than getting the user inside the route body?
A: It’s testable (swap the dependency in pytest with a mock user), DRY (every endpoint gets the same logic), and declarative (route signatures read like contracts).
Q2: When would you choose HTTP cookies over OAuth2PasswordBearer?
A: Cookies for browser-only SPAs where same-origin applies; Bearer for mobile + CLI + 3rd-party API. Bearer travels correctly across hosts; cookies don’t.
Q3: How do you handle token refresh without forcing the client to re-login?
A: Emit two tokens at login: short-lived (5 min) access_token and longer (7 day) refresh_token. Client calls /auth/refresh when the access token expires. Both are JWTs; the refresh token is stored server-side so it can be revoked.