Securing API Routes with FastAPI Scopes
Scopes in OAuth2 define fine-grained permissions: read:users allows reading user data, write:posts allows creating posts. FastAPI's SecurityScopes dependency lets you declare required scopes on each route and automatically validate that the token includes them. This is the foundation of least-privilege access—users and services get only the minimum permissions they need.
What Are Scopes?
A scope is a string representing a permission or capability. Examples: read:profile, write:data, delete:posts, admin:all. When a user logs in or grants authorization, their token includes an array of scopes. On protected routes, you check if the token's scopes include the required ones. Scopes are part of OAuth2's standard (RFC 6749).
Scopes differ from roles (which we'll cover in a later article):
| Aspect | Scope | Role |
|---|---|---|
| Granularity | Fine-grained (action + resource) | Coarse (user category) |
| Standard | OAuth2 native | Custom, application-defined |
| Typical Use | Delegated access (third-party apps) | Internal access control (employees, customers) |
Designing Your Scope Hierarchy
Effective scopes follow a resource:action pattern:
users:read— read user datausers:write— create/update usersusers:delete— delete usersposts:read— read postsposts:write— create postsadmin:*— all admin actions (wildcard)
Alternatively, use action-first: read:all, write:protected, delete:own. Pick one pattern and stick with it. Some APIs (Google, GitHub) use domain-specific scopes: https://www.googleapis.com/auth/drive, repo:status.
Storing Scopes in JWT Tokens
When issuing a JWT, include the scope claim (space-separated string per OAuth2 spec):
# security.py
from jose import jwt
from config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
from datetime import datetime, timedelta
def create_access_token(
data: dict,
scopes: list[str] | None = None,
expires_delta: timedelta | None = None
) -> str:
"""Create JWT with scopes."""
to_encode = data.copy()
# Add scopes as space-separated string
if scopes:
to_encode["scopes"] = " ".join(scopes)
else:
to_encode["scopes"] = ""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
Validating Scopes on Protected Routes
FastAPI's SecurityScopes object contains the scopes required by the route. Extract it from your token dependency:
# security.py (continued)
from fastapi import Depends, HTTPException, status, SecurityScopes
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError
bearer_scheme = HTTPBearer()
def decode_token(token: str) -> dict | None:
"""Decode JWT; return None if invalid."""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
return None
async def get_current_user(
security_scopes: SecurityScopes,
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)
) -> dict:
"""Validate token and required scopes."""
token = credentials.credentials
payload = decode_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token"
)
username = payload.get("sub")
token_scopes = payload.get("scopes", "").split()
# Check if token includes all required scopes
for scope in security_scopes.scopes:
if scope not in token_scopes:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Not enough permissions. Required scope: {scope}"
)
return {"username": username, "scopes": token_scopes}
Declaring Scopes on Routes
Use FastAPI's Security (not Depends) to declare required scopes:
# main.py
from fastapi import FastAPI, Security, Depends
from schemas import UserResponse
app = FastAPI()
@app.get("/profile", response_model=UserResponse)
async def get_profile(
current_user: dict = Security(get_current_user, scopes=["users:read"])
):
"""Read user profile. Requires 'users:read' scope."""
return {"username": current_user["username"]}
@app.post("/profile")
async def update_profile(
current_user: dict = Security(get_current_user, scopes=["users:write"])
):
"""Update user profile. Requires 'users:write' scope."""
return {"message": f"Profile updated for {current_user['username']}"}
@app.delete("/users/{user_id}")
async def delete_user(
user_id: str,
current_user: dict = Security(get_current_user, scopes=["users:delete", "admin:*"])
):
"""Delete a user. Requires 'users:delete' OR 'admin:*' scope."""
# Check if user has either scope
if "users:delete" not in current_user["scopes"] and "admin:*" not in current_user["scopes"]:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Insufficient permissions")
return {"message": f"User {user_id} deleted"}
@app.post("/login")
async def login(credentials: LoginRequest):
"""Issue token with user's scopes."""
user = users_db.get(credentials.username)
if not user or not verify_password(credentials.password, user["hashed_password"]):
raise HTTPException(status_code=401, detail="Invalid credentials")
# Assign scopes based on user role (example: regular users get read access)
user_scopes = ["users:read", "posts:read"]
if user.get("is_admin"):
user_scopes = ["users:read", "users:write", "users:delete", "posts:read", "posts:write", "admin:*"]
token = create_access_token(
data={"sub": credentials.username},
scopes=user_scopes
)
return {"access_token": token, "token_type": "bearer"}
Wildcard Scopes
For admin or super-user patterns, use wildcards:
def has_scope(token_scopes: list[str], required_scope: str) -> bool:
"""Check if token has required scope, respecting wildcards."""
# Direct match
if required_scope in token_scopes:
return True
# Wildcard match: if required is "users:read", wildcard "users:*" or "admin:*" works
parts = required_scope.split(":")
if len(parts) == 2:
resource = parts[0]
wildcard_resource = f"{resource}:*"
admin_wildcard = "admin:*"
if wildcard_resource in token_scopes or admin_wildcard in token_scopes:
return True
return False
Then in your dependency:
async def get_current_user_with_wildcard(
security_scopes: SecurityScopes,
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)
) -> dict:
"""Validate token with wildcard scope support."""
token = credentials.credentials
payload = decode_token(token)
if payload is None:
raise HTTPException(status_code=401, detail="Invalid token")
token_scopes = payload.get("scopes", "").split()
for scope in security_scopes.scopes:
if not has_scope(token_scopes, scope):
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
return {"username": payload.get("sub"), "scopes": token_scopes}
Auto-Generated OpenAPI Documentation
FastAPI's Security automatically documents scopes in OpenAPI (Swagger UI). Visit http://localhost:8000/docs to see scope requirements listed on each endpoint. This helps frontend developers understand what tokens they need.
Testing Scope Validation
# test_scopes.py
import pytest
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_endpoint_requires_users_read_scope(mock_token_with_scopes):
"""Test that endpoint rejects token without required scope."""
# Token with only "posts:read" scope
token = create_access_token(
data={"sub": "alice"},
scopes=["posts:read"]
)
response = client.get(
"/profile",
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 403
assert "Not enough permissions" in response.json()["detail"]
def test_endpoint_accepts_sufficient_scope():
"""Test that endpoint accepts token with required scope."""
token = create_access_token(
data={"sub": "alice"},
scopes=["users:read"]
)
response = client.get(
"/profile",
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
Key Takeaways
- Scopes define fine-grained permissions following
resource:actionpatterns. - Embed scopes in JWT tokens and validate them on protected routes using
SecurityScopes. - Wildcard scopes (
resource:*,admin:*) simplify permission hierarchies. - FastAPI auto-generates OpenAPI docs showing scope requirements per endpoint.
- Least-privilege: grant users only the minimum scopes they need.
Frequently Asked Questions
Can a user have scopes that differ by endpoint?
Technically, a token has one fixed set of scopes. However, your app logic can make each endpoint interpret scopes differently. Example: post:write might mean "write own posts" on /my-posts but "write any post" on /admin/posts.
How do I revoke a specific scope without re-issuing a token?
JWTs can't be modified after issue. To revoke a scope, either (1) add the token to a blacklist (limits scalability) or (2) use short-lived access tokens and issue new ones without the scope. Refresh tokens provide the latter pattern.
Should I store scopes in a database or in the token?
In JWT tokens: scopes travel with the token and don't require database queries. In a database: you can revoke scopes instantly without waiting for token expiration. A hybrid approach: embed scopes in short-lived access tokens, but store authoritative scopes in a database; check the database on refresh.
Is there a standard scope naming convention?
OAuth2 has no official convention. Google uses full URLs (https://www.googleapis.com/auth/drive). GitHub uses repo, gist, user. Pick a clear pattern and document it. resource:action is widely recognized.