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

Sponsored

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:

  1. Declarative security — the endpoint’s required scope is visible in the route decorator.
  2. Reusability — write the check once as a dependency and reuse it everywhere.
  3. 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:

  1. The client sends a request with an Authorization: Bearer <token> header.
  2. FastAPI runs the get_current_user dependency, which decodes the JWT and returns the user object (or raises 401 if invalid).
  3. A second dependency, require_scope("tickets:delete"), inspects the user’s scopes list.
  4. 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 Security instead of Depends, 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 scopes list in a JWT.
  • How to build a require_scope dependency 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 scopes in the JWT payload — the token has no permissions, so every endpoint returns 403.
  • Using inconsistent scope naming, like mixing tickets.delete and tickets: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

  1. Use Security instead of Depends in route definitions to display required scopes in the OpenAPI docs.
  2. Store permissions in a database and load them per request for real-time revocation.
  3. 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:read and tickets:write, but only managers can tickets:delete.
  • A content management API where editors can posts:write, but only admins hold posts:publish.
  • A financial app where support staff can invoices:read, while accountants also have invoices:write.

Key takeaways

  • Scoped permissions separate authentication (who you are) from authorization (what you can do).
  • Embed a scopes list 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:action and constants to avoid errors.
  • Remember that tokens carry scopes at creation time — plan for revocation or short expiry if permissions change.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.