Scoped Permissions in FastAPI
Learn to secure FastAPI endpoints with scoped permissions—understand the concept, apply it step by step, and troubleshoot common edge cases in this hands-on lesson.
Focus: securing endpoints with scoped permissions
You’ve built authentication into your FastAPI API — users can log in, get a token, and hit protected routes. But here’s the gap: every authenticated user has the same power. A regular user can delete critical data, and an intern can access admin-only reports. That’s not security; it’s a backdoor. In this lesson, you’ll learn securing endpoints with scoped permissions — the pattern that lets you define exactly what a user can do, not just who they are. By the end, you’ll transform a flat “login or not” check into a fine-grained authorization layer that production APIs rely on.
The problem this lesson solves
Authentication answers “who are you?” — typically via a JWT or session. Authorization answers “what can you do?” and that’s where most tutorials stop short. If your FastAPI app only checks that a token is valid, every endpoint is equally accessible to every logged-in user. That’s fine for a toy project, but in the real world:
- A support agent should read tickets but not escalate or refund them.
- A content editor should draft posts but not publish or delete them.
- A billing admin should view invoices but never modify pricing tiers.
Without scoped permissions, you’re forced to expose monolithic endpoints or sprinkle fragile if user.role == 'admin' checks across your codebase. That approach breaks as soon as roles multiply — suddenly you have if user.role in ['admin', 'superadmin', 'owner', 'manager'] that you have to update in ten places.
Scoped permissions solve this by attaching a list of permission strings to each user. Instead of asking “is this user an admin?”, you ask “does this user hold the tickets:delete permission?” The endpoint declares what it needs, and a reusable dependency checks the user’s token, extracts their permissions, and grants or denies access. This is the same model used by OAuth2 scopes, GitHub personal access tokens, and cloud IAM roles — and you can implement it cleanly in FastAPI with dependency injection.
Core concept / mental model
Think of permissions as keys. Authentication is the lock on the front door — it gets you inside the building. Scoped permissions are the keys to individual rooms. A user can have a keyring with several keys: tickets:read, tickets:write, users:admin. Each endpoint is a room that requires at least one specific key.
A common pattern is to encode these keys in the JWT itself, nested under a scopes claim. For example:
{
"sub": "user_123",
"scopes": ["tickets:read", "tickets:write"]
}
Your FastAPI dependency reads the token, extracts scopes, and compares them against the required permissions for the endpoint. If there’s a match, the request proceeds; if not, FastAPI returns a 403 Forbidden response.
This model gives you three big advantages:
- Declarative security — the endpoint’s required scope is visible in the route decorator.
- Reusability — write the check once as a dependency and reuse it everywhere.
- Scalability — adding a new permission is as simple as adding a string; no need to touch user-role logic everywhere.
Pro tip: You can also store scopes in a database and attach them to roles. But for stateless JWTs, embedding them in the token is simpler and avoids a database hit on every request.
How it works step by step
Here’s the flow every protected endpoint will follow:
- The client sends a request with an
Authorization: Bearer <token>header. - FastAPI runs the
get_current_userdependency, which decodes the JWT and returns the user object (or raises401if invalid). - A second dependency,
require_scope("tickets:delete"), inspects the user’sscopeslist. - If the required scope is present, the endpoint executes. If not, FastAPI raises
HTTPException(status_code=403).
You’ll implement this with FastAPI’s dependency factory — a function that takes a scope argument and returns a dependency function. Here’s the skeleton:
from fastapi import Depends, HTTPException, status
def require_scope(required_scope: str):
def scope_checker(user: dict = Depends(get_current_user)) -> dict:
if required_scope not in user.get("scopes", []):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required scope: {required_scope}"
)
return user
return scope_checker
The Depends(get_current_user) runs first, ensuring the user is authenticated. Then the scope check runs, ensuring they’re authorized. FastAPI’s dependency tree handles the order automatically — that’s the elegance of dependency injection.
Hands-on walkthrough
Let’s build a minimal but complete example. We’ll set up a token creation endpoint (simulating login), then protect two routes with different scopes.
Step 1: Define a fake user database and JWT creation
We’ll use python-jose for JWT encoding and passlib for password hashing (already covered in earlier lessons). For this walkthrough, we’ll keep passwords plain to focus on scopes.
from jose import jwt
from datetime import datetime, timedelta
SECRET_KEY = "your-secret-key"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
# In a real app, this comes from a database
USERS = {
"alice": {
"username": "alice",
"password": "secret",
"scopes": ["tickets:read", "tickets:write"]
},
"bob": {
"username": "bob",
"password": "secret",
"scopes": ["tickets:read"]
}
}
def create_access_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def authenticate_user(username: str, password: str):
user = USERS.get(username)
if not user or user["password"] != password:
return None
return user
Step 2: Build the auth dependencies
Now the get_current_user dependency and the require_scope factory:
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = USERS.get(username)
if user is None:
raise credentials_exception
return user
# Scope guard factory
def require_scope(required_scope: str):
def scope_checker(user: dict = Depends(get_current_user)) -> dict:
if required_scope not in user.get("scopes", []):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient scope: {required_scope}"
)
return user
return scope_checker
Step 3: Protect endpoints with scopes
from fastapi import FastAPI
app = FastAPI()
@app.post("/token")
async def login(username: str, password: str):
user = authenticate_user(username, password)
if not user:
raise HTTPException(status_code=400, detail="Incorrect username or password")
token = create_access_token(data={"sub": user["username"], "scopes": user["scopes"]})
return {"access_token": token, "token_type": "bearer"}
# Any authenticated user can read tickets
@app.get("/tickets")
async def read_tickets(user: dict = Depends(get_current_user)):
return {"message": "You can read tickets", "user": user["username"]}
# Only users with 'tickets:write' can create tickets
@app.post("/tickets")
async def create_ticket(user: dict = Depends(require_scope("tickets:write"))):
return {"message": "Ticket created", "user": user["username"]}
Step 4: Test it
Run the app with uvicorn main:app --reload, then open the interactive docs at http://127.0.0.1:8000/docs. Log in as Bob (who has tickets:read only) to get a token. Now try calling GET /tickets — it works. Call POST /tickets — you get a 403 Forbidden with the message Insufficient scope: tickets:write.
Log in as Alice, and POST /tickets succeeds.
Pro tip: You can also integrate scope checks into your OpenAPI schema by using
Securityinstead ofDepends, which shows a lock icon and a list of required scopes in the docs. Example:user: dict = Security(require_scope("tickets:write"), scopes=["tickets:write"]).
Compare options / when to choose what
When deciding how to implement scoped permissions, you have several options. Here’s a comparison:
| Approach | Pros | Cons | Best For |
|---|---|---|---|
| JWT-embedded scopes (this lesson) | Stateless, fast, no DB hits, easy to inspect | Hard to revoke instantly; token size grows with many scopes | Microservices, APIs with short-lived tokens |
| Database-stored permissions | Granular, revocable in real-time | Adds a DB query per request; more complex | Systems with frequent permission changes |
| Role-based access control (RBAC) | Simple to manage, easy to reason about | Coarse-grained; can’t express “delete only own posts” | Admin panels, small teams |
| OAuth2 scopes (framework) | Industry standard, integrates with third-party clients | Requires OAuth2 server setup, more moving parts | Public APIs where partners need limited access |
What I recommend
For most FastAPI applications, start with JWT-embedded scopes because it keeps your auth layer stateless and your dependencies clean. You can always migrate to database-stored permissions later if you need on-demand revocation. Combine scopes with role inheritance: for example, an admin role might include all scopes, while a viewer only has read scopes. You can encode inheritance by expanding the role to a list of scopes at login time.
A middle ground is to store scopes in the user record but still embed them in the JWT at login; that way you can bump the secret or force re-login to revoke, which is acceptable for many apps.
Troubleshooting & edge cases
Even with a clean pattern, you’ll hit snags. Here are the most common ones:
1. The WWW-Authenticate header is missing on 403
Your client may rely on this header to know the token is expired. For 401 errors, FastAPI sends it automatically when you set headers={"WWW-Authenticate": "Bearer"} in the exception. For 403, consider adding a similar header or a custom error code to help clients distinguish between “not logged in” and “not allowed.”
2. Scopes are missing from the token
You forgot to include scopes in the JWT payload. Check your create_access_token call — you must copy the user’s scopes into the token’s data dict. If the claim is absent, user.get("scopes", []) returns an empty list, and every protected endpoint will 403.
3. Scope names are inconsistent
Use resource:action format everywhere: tickets:read, tickets:write. If you mix tickets.delete and tickets:delete, your checks will silently fail. Create constants to avoid typos:
class Scopes:
TICKETS_READ = "tickets:read"
TICKETS_WRITE = "tickets:write"
4. Token expiration vs. scope change
If a user’s permissions change, an existing token still carries the old scopes until it expires. If you need instant revocation, look at token revocation lists or short-lived tokens (e.g., 5 minutes).
5. Getting 403 when you think you’re authorized
Double-check that the scope string matches exactly. Also, verify your dependency order — require_scope must depend on get_current_user to ensure the user is loaded first.
What you learned & what's next
In this lesson, you learned that securing endpoints with scoped permissions is about moving from binary access control to fine-grained, declarative authorization. You now know:
- How to embed a
scopeslist in a JWT. - How to build a
require_scopedependency factory to guard endpoints. - How to choose between JWT-embedded scopes, database permissions, RBAC, and OAuth2 scopes.
- How to troubleshoot common pitfalls like missing scopes, inconsistent naming, and token revocation.
These skills directly build on your authentication work and set you up for the next lesson in the track, where you’ll likely explore role hierarchies — combining roles and permissions into a unified system that scales with your API. You might also dive into testing authorization — writing unit tests that verify each endpoint rejects requests without the required scope.
You now have the tools to build APIs that trust no one by default and give each user exactly the access they need. That’s not just good security; it’s the foundation of a professional-grade backend.
Practice recap
Build a simple API with two user accounts (one with tickets:read and tickets:write, one with only tickets:read). Protect a POST /tickets endpoint with require_scope("tickets:write") and test both users in the interactive docs. Then add a tickets:delete scope to Alice and create a new endpoint that only she can access — verify Bob gets a 403.
Common mistakes
- Forgetting to include
scopesin the JWT payload — the token has no permissions, so every endpoint returns 403. - Using inconsistent scope naming, like mixing
tickets.deleteandtickets:write— comparisons fail silently. - Putting the scope check after the endpoint logic — secure the dependency first, otherwise unauthorized code runs.
- Storing scopes only in a database but not in the token, then wondering why the dependency can’t see them.
Variations
- Use
Securityinstead ofDependsin route definitions to display required scopes in the OpenAPI docs. - Store permissions in a database and load them per request for real-time revocation.
- Implement role-permission mapping where a role expands to a list of scopes at login time.
Real-world use cases
- A SaaS ticketing system where agents have
tickets:readandtickets:write, but only managers cantickets:delete. - A content management API where editors can
posts:write, but only admins holdposts:publish. - A financial app where support staff can
invoices:read, while accountants also haveinvoices:write.
Key takeaways
- Scoped permissions separate authentication (who you are) from authorization (what you can do).
- Embed a
scopeslist in your JWT to keep authorization stateless and fast. - Use a dependency factory like
require_scope()to declaratively protect endpoints. - Choose between JWT-embedded scopes, database-stored permissions, RBAC, and OAuth2 based on your revocation needs.
- Keep scope naming consistent using
resource:actionand constants to avoid errors. - Remember that tokens carry scopes at creation time — plan for revocation or short expiry if permissions change.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.