Lesson 4 - Protecting Routes and Scopes
Welcome to Protecting Routes and Scopes
In Lesson 3 you built get_current_user: a dependency that decodes a signed JWT and hands you the user making the request. That answers one question — who are you? This lesson answers the next one — what are you allowed to do? The two are not the same. A logged-in user has proven their identity, but that doesn’t mean they should be able to delete other people’s data or reach an admin-only endpoint. The first question is authentication; the second is authorization, and a production API needs both.
The good news: you already have the tools. Authorization in FastAPI is just more dependency injection. You protect a route by depending on the current user, and you add a permission check by writing a small dependency that builds on get_current_user and refuses the request when the rules aren’t met. Along the way you’ll see exactly why a missing token gives a 401 while a logged-in-but-not-allowed user gives a 403 — a distinction that trips up a lot of people.
By the end of this lesson, you will be able to:
- Protect any route so only logged-in users can reach it
- Add a role or permission check as a dependency layered on
get_current_user - Tell
401(not authenticated) apart from403(authenticated but not authorized) - Explain where OAuth2 scopes fit for finer-grained control
We’ll keep building the Task Manager — this time so that anyone can read tasks, but only admins can delete them. Let’s begin.
Protecting a Route: Depend on the Current User
The simplest form of protection is “you must be logged in.” You already have a dependency for that — get_current_user from Lesson 3 — so protecting a route is just a matter of depending on it. Any endpoint that takes Depends(get_current_user) will only run for a request carrying a valid token; everything else is rejected before your function body executes.
Here’s the setup, with two users who differ only in their role: ada is an admin and bob is a regular user. Both log in the same way and both receive a real signed JWT.
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
SECRET_KEY = "CHANGE-ME-IN-PRODUCTION" # placeholder — load from a secret in production
ALGORITHM = "HS256"
app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
users = {
"ada": {"username": "ada", "role": "admin"},
"bob": {"username": "bob", "role": "user"},
}
def create_token(username: str) -> str:
payload = {"sub": username, "exp": datetime.now(timezone.utc) + timedelta(minutes=30)}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
@app.post("/token")
def login(form: OAuth2PasswordRequestForm = Depends()):
user = users.get(form.username)
if user is None:
raise HTTPException(status_code=401, detail="Bad credentials")
return {"access_token": create_token(user["username"]), "token_type": "bearer"}
def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
except jwt.PyJWTError:
raise HTTPException(status_code=401, detail="Could not validate credentials")
user = users.get(username)
if user is None:
raise HTTPException(status_code=401, detail="Could not validate credentials")
return userNow the protected route. Reading the task list should be available to any logged-in user, so it depends on get_current_user directly — no role check needed:
@app.get("/tasks")
def list_tasks(user: dict = Depends(get_current_user)):
return {"tasks": ["write tests", "ship feature"], "viewer": user["username"]}Both users log in and hit the route. Let’s confirm both can read:
from fastapi.testclient import TestClient
client = TestClient(app)
ada_token = client.post("/token", data={"username": "ada", "password": "x"}).json()["access_token"]
bob_token = client.post("/token", data={"username": "bob", "password": "x"}).json()["access_token"]
ada_h = {"Authorization": f"Bearer {ada_token}"}
bob_h = {"Authorization": f"Bearer {bob_token}"}
r = client.get("/tasks", headers=ada_h); print("ada ->", r.status_code, r.json())
r = client.get("/tasks", headers=bob_h); print("bob ->", r.status_code, r.json())ada -> 200 {'tasks': ['write tests', 'ship feature'], 'viewer': 'ada'}
bob -> 200 {'tasks': ['write tests', 'ship feature'], 'viewer': 'bob'}Both succeed, because both presented a valid token. The user parameter even gives you the caller’s identity for free — notice viewer reflects whoever made the request. That’s the baseline: depend on get_current_user and the route is “logged-in users only.” Next we restrict further.
Role Checks as a Layered Dependency
Deleting a task is more dangerous than reading one — say only admins may do it. You could put an if user["role"] != "admin" check inside the delete function, but that scatters permission logic across every protected route and is easy to forget. The cleaner pattern is to make the check its own dependency that sits on top of get_current_user.
require_admin depends on get_current_user to obtain the user, then inspects the role field. If the user isn’t an admin, it raises 403; otherwise it returns the user so the route can use it:
def require_admin(user: dict = Depends(get_current_user)):
if user["role"] != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return user
@app.delete("/tasks/{task_id}")
def delete_task(task_id: int, admin: dict = Depends(require_admin)):
return {"deleted": task_id, "by": admin["username"]}The route depends on require_admin, not on get_current_user directly — and that’s the whole trick. Because require_admin itself depends on get_current_user, FastAPI runs the chain in order: first decode the token and load the user, then enforce the role. The endpoint only runs if both steps pass. Let’s try the same delete as each user, and once with no token at all:
r = client.delete("/tasks/5", headers=ada_h); print("ada (admin) ->", r.status_code, r.json())
r = client.delete("/tasks/5", headers=bob_h); print("bob (user) ->", r.status_code, r.json())
r = client.delete("/tasks/5"); print("no token ->", r.status_code, r.json())ada (admin) -> 200 {'deleted': 5, 'by': 'ada'}
bob (user) -> 403 {'detail': 'Admin access required'}
no token -> 401 {'detail': 'Not authenticated'}Three different outcomes from one endpoint. Ada is an admin, so the chain passes and the delete runs. Bob is a valid, logged-in user — but not an admin — so require_admin stops him with 403. With no token, the request never even gets as far as the role check; it fails earlier with 401. This is composition: require_admin reuses authentication and adds authorization on top, and you can write require_editor, require_owner, or any other rule the same way without touching the route bodies.
401 vs 403: A Concrete Distinction
Those last two results — 403 for bob and 401 for the missing token — are worth slowing down on, because the two status codes mean genuinely different things and clients react to them differently.
401 Unauthorizedreally means unauthenticated: “I don’t know who you are.” The request has no valid credentials. A client seeing401should send the user to log in.403 Forbiddenmeans authenticated but not authorized: “I know exactly who you are, and you still can’t do this.” Logging in again won’t help — bob will keep getting403no matter how many times he re-authenticates, because his role simply doesn’t permit the action.
You can see the boundary clearly by hitting the plain protected route with no token:
r = client.get("/tasks"); print("GET /tasks no token ->", r.status_code, r.json())GET /tasks no token -> 401 {'detail': 'Not authenticated'}That 401 comes from OAuth2PasswordBearer (via get_current_user) before any of your logic runs — there’s no token, so there’s nothing to authorize yet. The 403, by contrast, comes from your require_admin dependency, which only gets to run because authentication already succeeded. The order is the mental model: authenticate first (401 if that fails), authorize second (403 if that fails).
401 = who are you; 403 = you can’t do this
Read 401 as “authenticate” (no valid identity — go log in) and 403 as “authorize” (identity known, action denied — logging in again won’t help). Build authentication and authorization as composable dependencies: get_current_user answers who, and small dependencies like require_admin layered on top answer what they may do. Keeping permission checks in dependencies — not scattered through route bodies — keeps every endpoint consistent and easy to reason about.
Where OAuth2 Scopes Fit
The role dependency works well and is all most APIs need, but it’s coarse: a user is either an admin or not. Sometimes you want finer control — “this token may read tasks but not delete them” — where permissions attach to the token itself rather than to a blanket role. That’s what OAuth2 scopes are for, and FastAPI has built-in support through Security and SecurityScopes.
The idea: when you issue a token you embed a list of scopes (for example ["tasks:read", "tasks:delete"]), and each route declares which scopes it requires. FastAPI then checks the token’s scopes against the route’s requirements and returns 403 when a needed scope is missing — even nicely advertising the required scope in the response so clients know what to ask for. It’s the same authenticate-then-authorize flow you just built, only the “what may they do” lives in the token’s scopes instead of a role field you look up.
For this course we’ll stick with the role-dependency approach: it’s simpler, it’s plenty for the Task Manager, and the pattern transfers directly. Just know that scopes exist as FastAPI’s first-class mechanism when you need per-permission, per-token granularity, and that the dependency thinking you’ve practiced here is exactly what they’re built on.
Practice Exercises
Exercise 1: A read-only-blocking dependency
You want a route that only admins may reach. Sketch the dependency you’d write and how the route would declare it. Which existing dependency must it build on, and what status code should it raise on failure?
Hint
Write a function like require_admin that takes user: dict = Depends(get_current_user), checks user["role"] != "admin", and raises HTTPException(status_code=403, ...) if so — otherwise returns user. The route declares it with Depends(require_admin). It must build on get_current_user so authentication runs first; failure is 403, not 401.
Exercise 2: Predict the status code
For the DELETE /tasks/{task_id} route, what status code does each of these get, and why? (a) bob (role user) with a valid token; (b) a request with no Authorization header at all; (c) ada (role admin) with a valid token.
Hint
(a) 403 — bob is authenticated but lacks the admin role, so require_admin rejects him. (b) 401 — no token means authentication fails first, before any role check runs. (c) 200 — ada passes both authentication and the role check, so the delete runs.
Exercise 3: Read vs. delete
Explain why GET /tasks depends on get_current_user while DELETE /tasks/{task_id} depends on require_admin. What would change if you swapped them?
Hint
Reading is allowed for any logged-in user, so get_current_user (authentication only) is enough. Deleting is admin-only, so it needs the extra authorization layer require_admin. Swapping them would let any logged-in user delete (too permissive) while blocking non-admins from even reading the list (too restrictive).
Summary
Authentication asks who are you; authorization asks what may you do — and a production API needs both. You protect a route by depending on get_current_user, which limits it to logged-in users and hands you the caller’s identity. You add finer control by writing a small dependency such as require_admin that builds on get_current_user, inspects the user (here, the role field), and raises 403 when the rule isn’t met. Because FastAPI runs the dependency chain in order, authentication happens first and authorization second — which is exactly why a missing token yields 401 while a logged-in-but-disallowed user yields 403. For per-permission, per-token granularity beyond simple roles, FastAPI offers built-in OAuth2 scopes, built on the same dependency foundation.
Key Concepts
- Authorization — controlling what a user may do, distinct from authenticating who they are.
- Protecting a route — depend on
get_current_user; the route then requires a valid token. - Layered permission dependency —
require_admindepends onget_current_user, then checks the rule and raises403. 401vs403—401= not authenticated (no valid token);403= authenticated but not allowed.- OAuth2 scopes — FastAPI’s built-in mechanism for finer, per-token permissions.
Why This Matters
Real APIs almost never treat all users equally — some can read, some can write, some can administer — and getting that wrong is a classic source of security holes. Building authorization as composable dependencies keeps permission logic in one place, consistent across every endpoint, instead of scattered through route bodies where it’s easy to forget. The 401-vs-403 distinction matters in practice too: clients and front ends branch on it (re-login on 401, show “not allowed” on 403), so returning the right code is part of a correct API. With protection and role checks in place, you’re ready to assemble everything into a complete, auth-protected service.
Next Steps
Continue to Lesson 5 - Guided Project: Auth-Protected API
Bring authentication, hashing, JWTs, and role checks together into one complete, secured Task Manager API.
Back to Module Overview
Return to the Authentication and Security module overview
Continue Building Your Skills
You can now protect routes for logged-in users, restrict sensitive actions to the right roles with a layered dependency, and return the correct 401 or 403 for each case. That completes the core of FastAPI security — identity, hashed passwords, signed tokens, and now permission checks. In the guided project that follows, you’ll combine all of it into a single, fully secured Task Manager: real login, hashed credentials, JWT-protected routes, and admin-only operations working together end to end.