Scopes & Permissions

Difficulty: Advanced

OAuth2 scopes provide fine-grained access control beyond simple authentication. Instead of just checking whether a user is logged in, scopes let you define specific permissions like 'users:read', 'users:write', 'admin', and check that the user's token includes the required scopes for each endpoint.

FastAPI has built-in support for OAuth2 scopes. You define available scopes when creating the OAuth2PasswordBearer instance, include the granted scopes in the JWT token, and use Security() with SecurityScopes to enforce scope requirements on individual endpoints.

Scopes are typically included as a claim in the JWT token. When the user logs in, the server determines their permissions based on their role or group membership, and includes those scopes in the token. The token might contain scopes: ['users:read', 'users:write'] for a regular user or scopes: ['users:read', 'users:write', 'admin'] for an admin.

FastAPI's SecurityScopes class provides the required scopes for the current endpoint. A dependency can compare the token's scopes against the required scopes and raise 401 or 403 if the token lacks the necessary permissions. This enables a single get_current_user dependency that handles both authentication and authorization.

Role-Based Access Control (RBAC) is a higher-level pattern built on top of scopes. You define roles (admin, editor, viewer) and map each role to a set of scopes. When a user logs in, their role determines which scopes are included in the token. This simplifies permission management - you change a user's role instead of managing individual scopes.

The Swagger UI documentation shows the required scopes for each endpoint and allows users to request specific scopes when authenticating. This makes the security model visible and testable directly from the docs.

Code examples

Basic Scope-Based Authorization

from fastapi import FastAPI, Depends, HTTPException, Security
from fastapi.security import OAuth2PasswordBearer, SecurityScopes

app = FastAPI()

oauth2_scheme = OAuth2PasswordBearer(
    tokenUrl="token",
    scopes={
        "users:read": "Read user data",
        "users:write": "Create and update users",
        "admin": "Full administrative access",
    },
)

# Simulated token -> scopes mapping
token_scopes = {
    "user-token": ["users:read"],
    "editor-token": ["users:read", "users:write"],
    "admin-token": ["users:read", "users:write", "admin"],
}

def get_current_user(
    security_scopes: SecurityScopes,
    token: str = Depends(oauth2_scheme),
):
    user_scopes = token_scopes.get(token, [])

    # Check required scopes
    for scope in security_scopes.scopes:
        if scope not in user_scopes:
            raise HTTPException(
                status_code=403,
                detail=f"Missing required scope: {scope}",
                headers={"WWW-Authenticate": f'Bearer scope="{security_scopes.scope_str}"'},
            )

    return {"token": token, "scopes": user_scopes}

# Endpoints with different scope requirements
@app.get("/users")
def list_users(user: dict = Security(get_current_user, scopes=["users:read"])):
    return {"users": [], "accessed_by": user}

@app.post("/users")
def create_user(user: dict = Security(get_current_user, scopes=["users:write"])):
    return {"created": True}

@app.delete("/users/{user_id}")
def delete_user(
    user_id: int,
    user: dict = Security(get_current_user, scopes=["admin"]),
):
    return {"deleted": user_id}

Security() is like Depends() but also passes scope requirements to the dependency via SecurityScopes. The get_current_user dependency checks that the token has all required scopes. Different endpoints require different scopes.

Role-Based Access Control

from fastapi import FastAPI, Depends, HTTPException

app = FastAPI()

# Role -> scopes mapping
ROLE_SCOPES = {
    "viewer": ["read"],
    "editor": ["read", "write"],
    "admin": ["read", "write", "delete", "manage"],
}

# Simulated users with roles
users_db = {
    "alice": {"name": "Alice", "role": "admin"},
    "bob": {"name": "Bob", "role": "editor"},
    "charlie": {"name": "Charlie", "role": "viewer"},
}

def get_user_from_token(token: str):
    user = users_db.get(token)
    if not user:
        raise HTTPException(status_code=401, detail="Invalid token")
    return user

def require_scope(scope: str):
    def dependency(user: dict = Depends(get_user_from_token)):
        user_scopes = ROLE_SCOPES.get(user["role"], [])
        if scope not in user_scopes:
            raise HTTPException(
                status_code=403,
                detail=f"Requires '{scope}' permission",
            )
        return user
    return dependency

@app.get("/articles")
def list_articles(user: dict = Depends(require_scope("read"))):
    return {"articles": [], "user": user["name"]}

@app.post("/articles")
def create_article(user: dict = Depends(require_scope("write"))):
    return {"created": True, "user": user["name"]}

@app.delete("/articles/{id}")
def delete_article(id: int, user: dict = Depends(require_scope("delete"))):
    return {"deleted": id}

require_scope is a dependency factory that returns a dependency checking for a specific scope. Roles are mapped to scopes, so changing a user's role automatically changes their permissions. This is a clean RBAC implementation.

Permission Decorators

from fastapi import FastAPI, Depends, HTTPException
from functools import wraps

app = FastAPI()

users_db = {
    "admin-key": {"name": "Alice", "permissions": ["read", "write", "admin"]},
    "user-key": {"name": "Bob", "permissions": ["read"]},
}

def get_current_user(x_api_key: str = Depends(lambda x_api_key="": x_api_key)):
    # Simplified - in production use proper auth
    return users_db.get(x_api_key)

def has_permission(*required_permissions: str):
    """Dependency factory for permission checking."""
    def checker(user: dict | None = Depends(get_current_user)):
        if not user:
            raise HTTPException(status_code=401, detail="Not authenticated")
        user_perms = set(user.get("permissions", []))
        required = set(required_permissions)
        if not required.issubset(user_perms):
            missing = required - user_perms
            raise HTTPException(
                status_code=403,
                detail=f"Missing permissions: {', '.join(missing)}",
            )
        return user
    return checker

@app.get("/data")
def read_data(user: dict = Depends(has_permission("read"))):
    return {"data": "public", "user": user["name"]}

@app.post("/data")
def write_data(user: dict = Depends(has_permission("read", "write"))):
    return {"written": True}

@app.get("/admin")
def admin_panel(user: dict = Depends(has_permission("admin"))):
    return {"admin": True}

has_permission is a dependency factory that creates dependencies requiring specific permissions. Multiple permissions can be required (all must be present). Missing permissions are listed in the error message for debugging.

Key points

Concepts covered

OAuth2 Scopes, SecurityScopes, Fine-Grained Access, RBAC, Permission Checks