Skip to main content

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):

AspectScopeRole
GranularityFine-grained (action + resource)Coarse (user category)
StandardOAuth2 nativeCustom, application-defined
Typical UseDelegated access (third-party apps)Internal access control (employees, customers)

Designing Your Scope Hierarchy

Effective scopes follow a resource:action pattern:

  • users:read — read user data
  • users:write — create/update users
  • users:delete — delete users
  • posts:read — read posts
  • posts:write — create posts
  • admin:* — 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:action patterns.
  • 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.

Further Reading