Scopes and Role-Based Access Control

Authentication answers who you are; authorization answers what you may do. FastAPI supports two patterns: OAuth2 scopes (fine-grained permissions in the token, spec-compliant) and role checks (simpler, application-level).

OAuth2 Scopes: Security + SecurityScopes

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

app = FastAPI()

oauth2_scheme = OAuth2PasswordBearer(
    tokenUrl="token",
    scopes={
        "items:read": "Read items",
        "items:write": "Create or update items",
        "admin": "Admin operations",
    },
)

def get_current_user(security_scopes: SecurityScopes, token: str = Depends(oauth2_scheme)):
    payload = decode_jwt(token)                    # as in the previous page
    token_scopes = payload.get("scopes", [])
    # check that the token has every scope required by the endpoint
    for scope in security_scopes.scopes:
        if scope not in token_scopes:
            raise HTTPException(
                status_code=403,
                detail=f"Not enough permissions. Required: {security_scopes.scope_str}",
                headers={"WWW-Authenticate": f"Bearer scope=\"{security_scopes.scope_str}\""},
            )
    return get_user(payload["sub"])

@app.delete("/items/{item_id}", dependencies=[Security(get_current_user, scopes=["items:write"])])
def delete_item(item_id: int):
    return {"deleted": item_id}

Granting Scopes at Token Issuance

ROLE_SCOPES = {
    "user": ["items:read"],
    "editor": ["items:read", "items:write"],
    "admin": ["items:read", "items:write", "admin"],
}

@app.post("/token")
def login(form_data: OAuth2PasswordRequestForm = Depends()):
    user = authenticate_user(form_data.username, form_data.password)
    requested = set(form_data.scopes)                       # client-requested scopes
    allowed = set(ROLE_SCOPES[user.role])
    granted = list(requested & allowed)                     # intersection
    token = create_access_token({"sub": str(user.id), "scopes": granted})
    return {"access_token": token, "token_type": "bearer"}

Scopes requested by the client arrive in the token request as a space-separated string: scope=items:read items:write — grant only what the user actually has.

Simpler Alternative: Role-Based Dependencies

from functools import wraps

def require_role(*roles: str):
    async def checker(user = Depends(get_current_user)):
        if user.role not in roles:
            raise HTTPException(status_code=403, detail="Forbidden for your role")
        return user
    return checker

admin_router = APIRouter(
    prefix="/admin",
    dependencies=[Depends(require_role("admin"))],
)

@admin_router.post("/users/")
def admin_create_user(...): ...

Object-Level Authorization

@app.put("/items/{item_id}")
def update_item(item_id: int, item: ItemUpdate, user = Depends(get_current_user)):
    stored = items_repo.get(item_id)
    if stored is None:
        raise HTTPException(status_code=404, detail="Item not found")
    if stored.owner_id != user.id and user.role != "admin":
        raise HTTPException(status_code=403, detail="Not the owner")
    ...

Scopes/roles answer "can this user call this endpoint?"; ownership checks answer "can they touch this row?" — both are needed, and the second lives in the endpoint (or a dedicated dependency) because it needs the resource.

Quick Questions and Answers

Q: What status code should a permission failure return — 401 or 403?

A: 401 Unauthorized when there is no valid authentication (missing/invalid/expired token) and 403 Forbidden when the user is authenticated but lacks permission. Mixing them up confuses clients' retry logic.

Q: Do scopes appear in the Swagger UI?

A: Yes — the scopes={...} dictionary on OAuth2PasswordBearer renders as checkboxes in the "Authorize" dialog, so testers can obtain tokens with specific scopes and the docs show which endpoints need which scopes.

Q: Where should I enforce permissions — dependency or endpoint?

A: Route-level "can call this" checks belong in dependencies (reusable, visible in one place). Resource-level checks ("is this their row?") usually stay in the endpoint or a dependency that receives the path parameter — a dependency can read path/query parameters too, e.g. def owns_item(item_id: int, user=Depends(get_current_user)).

Conclusion

FastAPI makes authorization a first-class citizen: declare scopes on OAuth2PasswordBearer, enforce them with Security(...) and SecurityScopes, grant at token time from roles, and add ownership checks for object-level rules. The auth story from the last two pages is now complete.