Week 7: Authorization & Refresh Tokens

Last week you built a working login: a user registers, their password gets hashed, and a successful call to /token hands back a short-lived JWT that proves who they are. That token answers "who is this?" — it says nothing about "what are they allowed to do?", and its 15-minute expiry means a client either re-authenticates constantly or needs a safer way to renew access. This week adds both pieces on top of last week's authentication: scope-based checks that layer authorization onto the existing get_current_user dependency, and a refresh-token system that renews access safely while detecting and shutting down a stolen token the moment it gets reused.

Module 5 of 22 Week 7 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Layer scope checks on top of the existing get_current_user dependency to gate specific actions
  • Return 401 for authentication failures and 403 for authorization failures, correctly and consistently
  • Issue rotating refresh tokens and revoke an entire token family the moment a stolen token is reused

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.

dependencies.py
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.

dependencies.py
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.

The rule of thumb

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.

models.py
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)
routers/auth.py
@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"}
Store a hash, not the raw token

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

Hands-on

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:

  1. Add a scopes field to the user model (a list of strings) and give at least one test user the "tasks:delete" scope.
  2. Protect DELETE /tasks/{id} with Depends(require_scope("tasks:delete")) so only users carrying that scope can delete a task — everyone else gets 403.
  3. Add a RefreshToken table storing a hash of the token, a family_id, the owning user, an expiry, and revoked/rotated flags — never the raw token value.
  4. Extend login (/token) to also issue and store a refresh token, starting a brand-new family_id for that login.
  5. Add POST /token/refresh: validate the presented refresh token, and if it has already been rotated, revoke every token in its family and return 401; otherwise mark it rotated, and issue both a new access token and a new refresh token in the same family.
  6. Write a test that simulates theft: call /token/refresh once with token A to get token B, then call /token/refresh with token A again — assert that call fails with 401, and confirm token B (issued from the legitimate rotation) is now rejected too, because the whole family was revoked.
Hint

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?

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?

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?

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?

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.