1. Current-User Dependencies As Building Blocks
FastAPI's dependency system lets one dependency depend on another, and that's the
entire trick to layering authorization on top of authentication without
duplicating token logic. Last week's get_current_user already does
the hard part — extract the bearer token, verify its signature and expiry, look
up the matching user, and raise 401 on any failure along the way. Every additional
check this week — "is this user active," "does this user hold this scope," "is
this user an admin" — takes user = Depends(get_current_user) as a
parameter and adds one more condition on top, instead of re-implementing token
verification from scratch.
from fastapi import Depends, HTTPException, status
def get_current_active_user(user: User = Depends(get_current_user)) -> User:
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user")
return user
This composes cleanly because FastAPI resolves a dependency graph, not a flat
list. If three different parameters across a route (say, get_current_user
directly, plus two helpers that each depend on it) all resolve to the same
dependency within one request, FastAPI computes it once and reuses the cached
result for the rest of that request — the token isn't decoded three times just
because three things happen to need "the current user."
2. Scopes & Role-Based Permissions
There are two common models for deciding what an authenticated user can
do. Role-based access control gives each user a coarse label —
"admin", "editor", "viewer" — and each label
maps to a fixed bundle of permissions. Scope-based access control
is finer-grained: a user (or token) carries an explicit list of permission
strings like "tasks:delete" or "tasks:read", and each
protected action checks for one specific scope. Roles are simpler to reason about
but coarse — teams often end up minting a new "admin"-adjacent role just to grant
one narrow permission. Scopes compose better (a service-to-service token can be
issued exactly ["tasks:read"] and nothing else) at the cost of more
bookkeeping. Many real systems use both: a role is really just a convenient name
for a bundle of scopes, expanded once at login time.
def require_scope(required: str):
def check(user = Depends(current_user)):
if required not in user.scopes:
raise HTTPException(status_code=403, detail="Insufficient permission")
return user
return check
require_scope is a dependency factory: calling it
with a scope name returns a fresh dependency function closed over that
required value, so the same helper gates different routes with
different scopes — Depends(require_scope("tasks:delete")) on one
route, Depends(require_scope("tasks:write")) on another.
current_user here is last week's get_current_user (or a
layered variant like get_current_active_user) — authorization always
sits on top of authentication, never instead of it.
This is also where the 401 vs 403 distinction matters concretely.
401 Unauthorized means authentication itself failed or is
missing — no token, an expired token, a bad signature — exactly what
get_current_user raises. 403 Forbidden means
authentication succeeded — FastAPI knows precisely who this is — but that
identity isn't allowed to perform this specific action, which is what
require_scope raises. Mixing these up is a real, common bug:
returning 403 for "please log in" falsely implies logging in again won't help,
and returning 401 for "you're logged in, but not an admin" incorrectly suggests
re-authenticating would fix it when nothing about re-authenticating changes the
user's permissions.
401 means "I don't know who you are" — the fix is to authenticate. 403 means "I know exactly who you are, and the answer is still no" — no amount of re-authenticating changes that. If retrying the login would plausibly fix the response, it should be 401; if it wouldn't, it should be 403.
3. Refresh-Token Rotation & Revocation
Short-lived access tokens limit the blast radius of a stolen token, but they're
painful if that's the only token a client holds — nobody wants to
re-enter a password every 15 minutes. The fix is a second token: a
refresh token, long-lived (days to weeks) and, unlike the access
token, tracked server-side so it can actually be revoked. A client stores both;
when the access token expires, it calls /token/refresh with the
refresh token to get a new access token — and a new refresh token — without
touching the password again.
Rotation means issuing a brand-new refresh token on every
refresh call and immediately marking the one just used as spent. That makes each
refresh token effectively single-use: token A (from login) is exchanged for token
B, B is later exchanged for C, and so on. Every token descended from the same
login shares one family_id, forming a chain. If a refresh token ever
leaks, using it once — by whoever gets there first — invalidates it, which shrinks
the exploitable window from "the token's entire lifetime" down to "until the next
refresh call happens."
Reuse detection is what makes rotation actually protective instead of just tidy: if a client ever presents a refresh token that's already been marked rotated, that's a strong signal a copy of it escaped somewhere along the chain — a legitimate client only ever holds the newest token. At that point you can't tell whether the caller retrying with the old token is the attacker or the real client who lost a race, so the only safe response is to revoke the entire family — every token descended from that login — rather than just the one reused token. That forces everyone back through a full login, closing the door regardless of who currently holds the "valid" one.
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
token_hash: Mapped[str] = mapped_column(String(255), unique=True)
family_id: Mapped[str] = mapped_column(String(36), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
expires_at: Mapped[datetime]
revoked: Mapped[bool] = mapped_column(default=False)
rotated: Mapped[bool] = mapped_column(default=False)
@router.post("/token/refresh")
def refresh(body: RefreshRequest) -> dict[str, str]:
stored = get_refresh_token(hash_token(body.refresh_token))
if stored is None or stored.revoked or stored.expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=401, detail="Invalid refresh token")
if stored.rotated:
revoke_family(stored.family_id)
raise HTTPException(status_code=401, detail="Refresh token reuse detected")
stored.rotated = True
new_refresh = issue_refresh_token(stored.user_id, family_id=stored.family_id)
access_token = create_access_token(stored.user_id)
return {"access_token": access_token, "refresh_token": new_refresh, "token_type": "bearer"}
Treat a refresh token like a password-adjacent secret: store hash_token(raw_token), never the raw value, so a database leak doesn't hand out live, usable tokens. Unlike passwords, a plain fast hash like SHA-256 is fine here — bcrypt's slowness defends against guessing a low-entropy human password, but a refresh token is already a long, random, high-entropy value with nothing to brute-force.
4. Hands-on Exercise
Add admin-only task deletion and rotating refresh tokens
Layer a real permission check on top of last week's authentication, then replace "log in every 15 minutes" with a safe, revocable renewal flow.
Requirements:
- Add a
scopesfield to the user model (a list of strings) and give at least one test user the"tasks:delete"scope. - Protect
DELETE /tasks/{id}withDepends(require_scope("tasks:delete"))so only users carrying that scope can delete a task — everyone else gets403. - Add a
RefreshTokentable storing a hash of the token, afamily_id, the owning user, an expiry, andrevoked/rotatedflags — never the raw token value. - Extend login (
/token) to also issue and store a refresh token, starting a brand-newfamily_idfor that login. - Add
POST /token/refresh: validate the presented refresh token, and if it has already been rotated, revoke every token in its family and return401; otherwise mark it rotated, and issue both a new access token and a new refresh token in the same family. - Write a test that simulates theft: call
/token/refreshonce with token A to get token B, then call/token/refreshwith token A again — assert that call fails with401, and confirm token B (issued from the legitimate rotation) is now rejected too, because the whole family was revoked.
Build the reuse-detection test before you're confident the happy path works — it's easy to write a revoke_family that revokes the reused token but forgets every other token descended from the same family_id. The test in requirement 6 only passes if both A and its already-rotated successor B stop working after the reuse attempt.
5. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
When should an API return 401 versus 403?
When should an API return 401 versus 403?
Return 401 Unauthorized when authentication itself is missing or invalid — no token, an expired token, a bad signature — meaning the API doesn't know who's calling. Return 403 Forbidden when authentication succeeded and the caller's identity is known, but that identity lacks permission for the specific action requested. A useful test: if re-authenticating would plausibly change the outcome, it's 401; if the user could log in a hundred times and still be denied, it's 403.
Q2
Why rotate refresh tokens on every use instead of letting a client reuse the same refresh token for its entire lifetime?
Why rotate refresh tokens on every use instead of letting a client reuse the same refresh token for its entire lifetime?
Rotation makes each refresh token single-use: if one is ever intercepted, it becomes worthless the moment either the legitimate client or the attacker redeems it, which shrinks the exploitable window from "the refresh token's entire multi-day lifetime" down to "until the next refresh call." Without rotation, a stolen long-lived refresh token would remain fully valid for its whole lifetime, and there would be no signal anywhere in the system that a theft had even occurred.
Q3
What does revoking a refresh token's entire "family" actually protect against, and why not just revoke the one reused token?
What does revoking a refresh token's entire "family" actually protect against, and why not just revoke the one reused token?
Seeing an already-rotated token reused only proves that a copy escaped somewhere in the chain — it doesn't tell you which side (the real client or the attacker) is currently holding the newest, still-valid token. Revoking just the reused token leaves the rest of that chain trusted, so if the attacker happens to be the one holding the current token, they keep full access. Revoking the entire family invalidates every token descended from that login regardless of who holds what, forcing both parties back through a full re-login and closing the gap even though you can't tell attacker from victim.
Q4
Why does require_scope take a user = Depends(current_user) parameter instead of decoding the JWT itself?
Why does require_scope take a user = Depends(current_user) parameter instead of decoding the JWT itself?
require_scope's job is purely an authorization decision — does this already-identified user hold this permission — and it shouldn't duplicate get_current_user's authentication logic (signature verification, expiry check, user lookup, 401 handling). By depending on the existing authentication dependency, FastAPI resolves and caches that work once per request, and require_scope stays focused on a single concern: checking the scopes already attached to the resolved user object and raising 403 if the required one is missing.