OAuth2 Scopes & Authorization

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13, Starlette 1.7 and PyJWT 2.15, checked on 2026-10-02. All code ran on Python 3.13. Authentication answers "who are you?" and was covered in OAuth2 Password Flow & JWT (login form, token endpoint, hashing, get_current_user). Authorization answers "what may you do?", and that is this page: scopes that travel inside the token, role- and object-level rules that live in the database, ownership checks, and what changes when the tokens come from somebody else’s identity provider. The protocol theory (what a scope is, how OpenID Connect layers identity on top of OAuth 2.0) is in the OAuth Reference and is not repeated: see Scopes, Claims & Permissions, OpenID Connect and Social Login & Federation. Everything FastAPI-specific builds on Dependency Injection and the vocabulary in Security Fundamentals.

Choosing an Authorization Model

FastAPI gives you one mechanism, dependencies, and every model below is a dependency that raises HTTPException(403) when the rule fails. They differ in where the decision data lives:

Model Decision data Good for Weakness

OAuth2 scopes

Inside the (signed) token, requested by the client at login

Limiting what a client or token may do on behalf of a user; third-party clients; the Swagger UI "Authorize" dialog

Frozen until the token expires; says nothing about which objects

Admin flag

One boolean on the user record

The smallest possible back office

All or nothing

RBAC (roles)

User → roles → permissions, read from your database

"Editors may write, readers may read"; changes take effect on the next request

Still blind to individual objects

ACL / ownership

A per-object list or an owner_id column

"Only the author may edit this review"

Needs a database lookup per request

They compose. A realistic Bookshelf API endpoint is guarded by a scope (the token may write books), then a permission (the user’s role may write books), then an ownership rule (this is their review). Scopes answer "is this token allowed to try this?"; roles and ownership answer "is this user allowed to do it to this thing?".

The Shared Pieces

Every example below imports a small auth.py. It reads the JWT signing key from settings (never from source code), hashes the demo passwords with Argon2 through pwdlib, and issues PyJWT tokens that carry the granted scopes in a space-delimited scope claim, which is the format RFC 6749 uses on the wire and RFC 9068 uses in JWT access tokens.

# auth.py - shared helpers for the examples on this page
from datetime import UTC, datetime, timedelta
from functools import lru_cache
from typing import Annotated

import jwt
from fastapi import Depends
from pwdlib import PasswordHash
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="BOOKSHELF_")

    # Required, no default. Generate one with: openssl rand -hex 32
    jwt_secret_key: str
    jwt_algorithm: str = "HS256"
    access_token_minutes: int = 15


@lru_cache
def get_settings() -> Settings:
    return Settings()


SettingsDep = Annotated[Settings, Depends(get_settings)]

password_hash = PasswordHash.recommended()  # Argon2


class User(BaseModel):
    username: str
    is_admin: bool = False
    roles: list[str] = []
    scopes: list[str] = []  # the most this user may ever be granted


class UserInDB(User):
    hashed_password: str


# Demo data. A real service reads users from a database.
fake_users_db: dict[str, UserInDB] = {
    "ada": UserInDB(
        username="ada",
        roles=["editor"],
        scopes=["me", "books:read", "books:write"],
        hashed_password=password_hash.hash("ada-demo-password"),
    ),
    "grace": UserInDB(
        username="grace",
        roles=["reader"],
        scopes=["me", "books:read"],
        hashed_password=password_hash.hash("grace-demo-password"),
    ),
    "root": UserInDB(
        username="root",
        is_admin=True,
        roles=["admin"],
        scopes=["me", "books:read", "books:write", "books:delete"],
        hashed_password=password_hash.hash("root-demo-password"),
    ),
}


def authenticate_user(username: str, password: str) -> UserInDB | None:
    user = fake_users_db.get(username)
    if user is None or not password_hash.verify(password, user.hashed_password):
        return None
    return user


def create_access_token(settings: Settings, sub: str, scopes: list[str], roles: list[str]) -> str:
    now = datetime.now(UTC)
    payload = {
        "sub": sub,
        "scope": " ".join(scopes),  # RFC 6749: space-delimited string
        "roles": roles,
        "iat": now,
        "exp": now + timedelta(minutes=settings.access_token_minutes),
    }
    return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)

Generate the signing key once per environment and export it; the application refuses to start without it because the field has no default:

export BOOKSHELF_JWT_SECRET_KEY="$(openssl rand -hex 32)"

The passwords above (ada-demo-password and friends) are fake demo values for the in-memory user table, nothing more. The user’s scopes field is the ceiling of what the user may ever be granted; the token carries the subset the client actually asked for.

OAuth2 Scopes with Security() and SecurityScopes

A scope is a short string (books:write) that names a permission. In OAuth2 the client asks for scopes when it requests a token, the server grants some or all of them, and the resource server (your API) checks that the token’s scopes cover what an endpoint needs. FastAPI turns that into four cooperating pieces:

Piece Role

OAuth2PasswordBearer(tokenUrl=…​, scopes={…​})

Declares the security scheme and the catalogue of scopes (name to human description). The catalogue appears in OpenAPI and in the Swagger UI "Authorize" dialog; it does not enforce anything.

OAuth2PasswordRequestForm.scopes

The scope form field of the token request, already split on spaces into a list.

Security(dependency, scopes=[…​])

Like Depends(), plus the scopes this endpoint (or dependency) needs. Accepts use_cache like Depends.

SecurityScopes

A special parameter type: declare it in a dependency and FastAPI fills it with the scopes required by the chain of Security() declarations that led to that dependency. .scopes is the list, .scope_str the same list as one space-separated string.

FastAPI does not read the token for you. Your dependency decodes it, extracts the granted scopes, and compares them with security_scopes.scopes. The complete application:

# scopes_app.py
from typing import Annotated

import jwt
from fastapi import Depends, FastAPI, HTTPException, Security, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm, SecurityScopes
from pydantic import BaseModel

from auth import (
    SettingsDep,
    User,
    authenticate_user,
    create_access_token,
    fake_users_db,
)

SCOPES = {
    "me": "Read your own profile.",
    "books:read": "Read books, authors and reviews.",
    "books:write": "Create and update books.",
    "books:delete": "Delete books.",
}

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", scopes=SCOPES)

app = FastAPI(title="Bookshelf API", version="0.1.0")


class Token(BaseModel):
    access_token: str
    token_type: str
    scope: str


@app.post("/token")
def login(
    form: Annotated[OAuth2PasswordRequestForm, Depends()], settings: SettingsDep
) -> Token:
    user = authenticate_user(form.username, form.password)
    if user is None:
        raise HTTPException(
            status.HTTP_401_UNAUTHORIZED,
            "Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    # The client asks; the server decides. Grant only what the user may have.
    requested = form.scopes or user.scopes
    granted = [s for s in requested if s in user.scopes]
    token = create_access_token(settings, user.username, granted, user.roles)
    return Token(access_token=token, token_type="bearer", scope=" ".join(granted))


async def get_current_user(
    security_scopes: SecurityScopes,
    token: Annotated[str, Depends(oauth2_scheme)],
    settings: SettingsDep,
) -> User:
    authenticate_value = (
        f'Bearer scope="{security_scopes.scope_str}"' if security_scopes.scopes else "Bearer"
    )
    try:
        payload = jwt.decode(
            token,
            settings.jwt_secret_key,
            algorithms=[settings.jwt_algorithm],
            options={"require": ["exp", "sub"]},
        )
    except jwt.InvalidTokenError:
        raise HTTPException(
            status.HTTP_401_UNAUTHORIZED,
            "Could not validate credentials",
            headers={"WWW-Authenticate": authenticate_value},
        ) from None
    user = fake_users_db.get(payload["sub"])
    if user is None:
        raise HTTPException(
            status.HTTP_401_UNAUTHORIZED,
            "Could not validate credentials",
            headers={"WWW-Authenticate": authenticate_value},
        )
    token_scopes = payload.get("scope", "").split()
    for scope in security_scopes.scopes:  # ALL required scopes must be present
        if scope not in token_scopes:
            raise HTTPException(
                status.HTTP_403_FORBIDDEN,
                "Not enough permissions",
                headers={"WWW-Authenticate": authenticate_value},
            )
    return user


CurrentUser = Annotated[User, Security(get_current_user, scopes=["me"])]


@app.get("/users/me")
async def read_me(current_user: CurrentUser) -> User:
    return current_user


@app.get("/books")
async def list_books(
    user: Annotated[User, Security(get_current_user, scopes=["books:read"])],
) -> list[dict]:
    return [{"id": 1, "title": "Dune", "requested_by": user.username}]


@app.post("/books", status_code=status.HTTP_201_CREATED)
async def create_book(
    user: Annotated[User, Security(get_current_user, scopes=["books:write"])],
) -> dict:
    return {"id": 2, "created_by": user.username}


@app.delete("/books/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_book(
    book_id: int,
    user: Annotated[User, Security(get_current_user, scopes=["books:delete"])],
) -> None:
    return None


# A dependency tree: scopes declared at every level are merged.
async def read_scope_probe(
    security_scopes: SecurityScopes,
    user: Annotated[User, Security(get_current_user, scopes=["books:read"])],
) -> list[str]:
    return security_scopes.scopes


@app.get("/debug/scopes")
async def debug_scopes(
    seen: Annotated[list[str], Security(read_scope_probe, scopes=["books:write"])],
) -> dict:
    return {"scopes_seen_by_the_probe": seen}

What each part does:

  • The scope catalogue (SCOPES) is passed to OAuth2PasswordBearer(scopes=…​). It is documentation for clients.

  • /token decides the grant. form.scopes is only a request. The server intersects it with user.scopes, so a client asking for books:delete on a user without it silently gets a narrower token (RFC 6749 allows the server to issue fewer scopes than requested and tell the client in the response scope field, which this code does). An empty request grants the user’s full set; some services prefer an empty default, which is a policy choice.

  • get_current_user runs once per Security() declaration and has two jobs: authenticate (validate signature, expiry, and that sub still exists) and authorize (every required scope must be in the token). Missing scope is 403; a bad or missing token is 401.

  • WWW-Authenticate: Bearer scope="…​" tells the client which scopes the endpoint needed, as RFC 6750 describes.

  • CurrentUser is a reusable Annotated alias, so "requires the me scope" is spelled once.

  • All required scopes must be present; there is no built-in "any of". Write that rule yourself in the dependency if you need it.

Try it, with the token elided:

curl -s -X POST http://127.0.0.1:8000/token \
  -d "username=ada&password=ada-demo-password&scope=me books:read books:delete"
{"access_token": "<jwt>", "token_type": "bearer", "scope": "me books:read"}
curl -si -X POST http://127.0.0.1:8000/books -H "Authorization: Bearer <jwt>"
HTTP/1.1 403 Forbidden
www-authenticate: Bearer scope="books:write"

{"detail":"Not enough permissions"}

Without any token the endpoint answers 401 with {"detail": "Not authenticated"} and WWW-Authenticate: Bearer. That is the behaviour of all FastAPI security classes since 0.122.0 (2025-11-24): missing credentials are a 401, where older releases (and older guides) answered 403.

The Scope Dependency Tree

Scopes accumulate from the endpoint downwards. Each Security() adds its own scopes to the ones declared above it, and a dependency receives the union of the scopes declared on the path from the path operation to itself, with the ancestors' scopes first:

# scope_tree.py
from typing import Annotated

from fastapi import FastAPI, Security
from fastapi.security import SecurityScopes
from fastapi.testclient import TestClient

app = FastAPI()
calls: list[list[str]] = []


def leaf(security_scopes: SecurityScopes) -> str:
    calls.append(list(security_scopes.scopes))
    return security_scopes.scope_str


def middle(
    security_scopes: SecurityScopes,
    leaf_scopes: Annotated[str, Security(leaf, scopes=["books:read"])],
) -> dict:
    return {"middle_sees": security_scopes.scopes, "leaf_sees": leaf_scopes}


@app.get("/tree")
def tree(
    result: Annotated[dict, Security(middle, scopes=["books:write"])],
    other_leaf: Annotated[str, Security(leaf, scopes=["me"])],
) -> dict:
    return {**result, "other_leaf_sees": other_leaf}


client = TestClient(app)
print(client.get("/tree").json())
print(calls)
print(client.get("/openapi.json").json()["paths"]["/tree"]["get"].get("security"))

Output of the request to /tree:

{"middle_sees": ["books:write"], "leaf_sees": "books:write books:read", "other_leaf_sees": "me"}
Dependency Declared with SecurityScopes.scopes inside it

middle

Security(middle, scopes=["books:write"]) on the endpoint

["books:write"]

leaf (via middle)

Security(leaf, scopes=["books:read"]) inside middle

["books:write", "books:read"]

leaf (directly)

Security(leaf, scopes=["me"]) on the endpoint

["me"]

Rules to remember:

  • Scopes flow down: a parent never sees the scopes its children declare, a child sees every ancestor’s.

  • The same dependency function used with different scopes is called once per distinct scope set (it ran twice above), because the per-request cache key includes the scopes. With identical scopes the cached result is reused.

  • In the generated OpenAPI, the operation’s security entry lists the union that reaches the security scheme. For the /debug/scopes route of the earlier application it is [{"OAuth2PasswordBearer": ["books:write", "books:read"]}], which is what the "Authorize" button and the padlock icons in Swagger UI use.

What Scopes Do to OpenAPI and Swagger UI

The OAuth2PasswordBearer above produces this scheme in /openapi.json:

{
  "OAuth2PasswordBearer": {
    "type": "oauth2",
    "flows": {
      "password": {
        "scopes": {
          "me": "Read your own profile.",
          "books:read": "Read books, authors and reviews.",
          "books:write": "Create and update books.",
          "books:delete": "Delete books."
        },
        "tokenUrl": "token"
      }
    }
  }
}

and each endpoint that uses Security() gets a "security": [{"OAuth2PasswordBearer": ["books:read"]}] entry. In Swagger UI (/docs), "Authorize" opens a dialog with a checkbox per scope; the ticked scopes are sent as the scope field to /token. The password flow is a first-party-client mechanism; see OAuth2 Password Flow & JWT and, for the protocol side, Legacy Implicit & Password Grants. The scopes mechanism is identical for the other FastAPI scheme classes. OAuth2AuthorizationCodeBearer (shown later) takes the same scopes argument and is the one to use when a real authorization server issues the tokens. More on how the document is produced is in OpenAPI & Interactive Docs.

Scopes on a Whole Router or Decorator

When the endpoint does not need the authenticated user, only the check, declare the dependency in the decorator or on the router. Security() works anywhere Depends() does:

@app.get("/reports", dependencies=[Security(get_current_user, scopes=["books:read"])])
async def reports() -> list[dict]:
    return []

reviews = APIRouter(prefix="/reviews", dependencies=[Security(get_current_user, scopes=["books:read"])])

Both forms were run: a books:read-less token gets 403, no token gets 401, and the OpenAPI operation lists books:read. This is the right shape for "every route in this router needs `books:read`" and keeps the scope visible in OpenAPI. The result of the dependency is discarded; add a second parameter if the function body needs the user. The same mechanism is described in Dependency Injection.

Testing Authorization

Each rule deserves a test that logs in as a user with and without the right to do something. The fixture sets the signing key (the settings object is cached, so the cache is cleared around each test):

# conftest.py
import secrets

import pytest


@pytest.fixture(autouse=True)
def jwt_secret(monkeypatch):
    from auth import get_settings

    monkeypatch.setenv("BOOKSHELF_JWT_SECRET_KEY", secrets.token_hex(32))
    get_settings.cache_clear()  # Settings is cached; rebuild it with the new variable
    yield
    get_settings.cache_clear()
# test_authorization.py
import pytest
from fastapi.testclient import TestClient

from rbac_app import app

client = TestClient(app)


def request_token(username: str, scope: str = "") -> dict:
    password = f"{username}-demo-password"  # the fake passwords from auth.py
    r = client.post("/token", data={"username": username, "password": password, "scope": scope})
    assert r.status_code == 200
    return r.json()


def login(username: str, scope: str = "") -> dict[str, str]:
    r = request_token(username, scope)
    return {"Authorization": f"Bearer {r['access_token']}"}


def test_server_grants_only_what_the_user_may_have():
    r = request_token("ada", "me books:read books:delete")
    assert r["scope"] == "me books:read"  # books:delete was silently dropped


def test_missing_scope_is_403_with_the_scope_in_www_authenticate():
    headers = login("ada", "me books:read")
    r = client.post("/books", headers=headers)
    assert r.status_code == 403
    assert r.headers["www-authenticate"] == 'Bearer scope="books:write"'


def test_no_credentials_is_401():
    assert client.get("/books").status_code == 401


@pytest.mark.parametrize(("user", "expected"), [("grace", 403), ("ada", 200)])
def test_rbac_permission(user, expected):
    assert client.put("/rbac/books/1", headers=login(user)).status_code == expected


def test_acl_hides_books_the_user_cannot_read():
    headers = login("grace")
    assert client.get("/acl/books/2", headers=headers).status_code == 404
    assert client.patch("/acl/books/1", headers=headers).status_code == 403


def test_only_the_owner_or_an_admin_deletes_a_review():
    assert client.delete("/reviews/11", headers=login("ada")).status_code == 403
    assert client.delete("/reviews/11", headers=login("root")).status_code == 204

Seven tests passed against the code on this page (pytest -q), and the seven OIDC tests further down passed as well. Starlette 1.7’s TestClient emits a deprecation warning when used with httpx; see Testing for what to do about it.

Admin Flag, RBAC and Per-Object ACLs as Dependencies

The next code is added to the same application. It shows the three database-backed models on one resource. Note that CurrentUser loads the user from the database for every request, so a role that was just revoked stops working immediately, unlike a scope that is frozen inside a live token until it expires.

# rbac_app.py - added to the same app as scopes_app.py
from typing import Annotated

from fastapi import Depends, HTTPException, Path, Security, status
from pydantic import BaseModel

from auth import User
from scopes_app import CurrentUser, app, get_current_user

# 1. The simplest model: an admin flag.
def require_admin(user: CurrentUser) -> User:
    if not user.is_admin:
        raise HTTPException(status.HTTP_403_FORBIDDEN, "Administrators only")
    return user


AdminUser = Annotated[User, Depends(require_admin)]


@app.get("/admin/stats")
def stats(admin: AdminUser) -> dict:
    return {"books": 2, "requested_by": admin.username}


# 2. RBAC: roles map to permissions; routes ask for a permission, never a role.
ROLE_PERMISSIONS: dict[str, set[str]] = {
    "reader": {"book:read"},
    "editor": {"book:read", "book:write"},
    "admin": {"book:read", "book:write", "book:delete", "user:manage"},
}


class RequirePermission:
    def __init__(self, permission: str) -> None:
        self.permission = permission

    def __call__(self, user: CurrentUser) -> User:
        granted: set[str] = set()
        for role in user.roles:
            granted |= ROLE_PERMISSIONS.get(role, set())
        if self.permission not in granted:
            raise HTTPException(
                status.HTTP_403_FORBIDDEN, f"Missing permission: {self.permission}"
            )
        return user


@app.put("/rbac/books/{book_id}")
def rbac_update_book(
    book_id: int,
    user: Annotated[User, Depends(RequirePermission("book:write"))],
) -> dict:
    return {"book_id": book_id, "updated_by": user.username}


# 3. A per-object ACL: who may do what on THIS book.
BOOK_ACL: dict[int, dict[str, set[str]]] = {
    1: {"read": {"ada", "grace"}, "write": {"ada"}},
    2: {"read": {"ada"}, "write": set()},
}


def require_book_access(action: str):
    def checker(
        book_id: Annotated[int, Path(ge=1)], user: CurrentUser
    ) -> User:
        acl = BOOK_ACL.get(book_id)
        if acl is None:
            raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
        if user.is_admin or user.username in acl.get(action, set()):
            return user
        # 404 instead of 403 hides the book's existence from users with no read access
        if user.username not in acl.get("read", set()):
            raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
        raise HTTPException(status.HTTP_403_FORBIDDEN, f"No {action} access to this book")

    return checker


@app.get("/acl/books/{book_id}")
def acl_read_book(
    book_id: int, user: Annotated[User, Depends(require_book_access("read"))]
) -> dict:
    return {"book_id": book_id, "viewer": user.username}


@app.patch("/acl/books/{book_id}")
def acl_write_book(
    book_id: int, user: Annotated[User, Depends(require_book_access("write"))]
) -> dict:
    return {"book_id": book_id, "editor": user.username}


# 4. Resource ownership: only the author of a review (or an admin) may change it.
class Review(BaseModel):
    id: int
    book_id: int
    owner: str
    text: str


REVIEWS: dict[int, Review] = {
    10: Review(id=10, book_id=1, owner="ada", text="Great."),
    11: Review(id=11, book_id=1, owner="grace", text="Dense."),
}


def get_owned_review(
    review_id: Annotated[int, Path(ge=1)],
    user: Annotated[User, Security(get_current_user, scopes=["books:write"])],
) -> Review:
    review = REVIEWS.get(review_id)
    if review is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "Review not found")
    if review.owner != user.username and not user.is_admin:
        raise HTTPException(status.HTTP_403_FORBIDDEN, "You can only change your own reviews")
    return review


OwnedReview = Annotated[Review, Depends(get_owned_review)]


@app.delete("/reviews/{review_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_review(review: OwnedReview) -> None:
    del REVIEWS[review.id]

How the three differ:

  • Admin flag (require_admin) is a dependency that depends on another dependency (CurrentUser) and adds one check. Composition through Annotated aliases (AdminUser) keeps the endpoint signature readable.

  • RBAC separates roles (what people are) from permissions (what the code asks for). Endpoints name a permission (book:write); only ROLE_PERMISSIONS knows which roles carry it. RequirePermission is a class with call, the pattern for parametrised dependencies: Depends(RequirePermission("book:write")) creates one configured instance per route. Adding a role is a data change, not a code change across endpoints. In a real service the mapping lives in tables (users, roles, role_permissions), not a dict.

  • ACL (require_book_access("read")) is a dependency factory (a function returning a function). It combines a path parameter with the user, the pattern for any per-object rule. It answers 404 rather than 403 for objects the user may not even read, so the API does not confirm that the object exists.

Run results (users grace = reader, ada = editor, root = admin):

GET    /admin/stats        grace -> 403 {"detail":"Administrators only"}
GET    /admin/stats        root  -> 200 {"books":2,"requested_by":"root"}
PUT    /rbac/books/1       grace -> 403 {"detail":"Missing permission: book:write"}
PUT    /rbac/books/1       ada   -> 200 {"book_id":1,"updated_by":"ada"}
GET    /acl/books/2        grace -> 404 {"detail":"Book not found"}
PATCH  /acl/books/1        grace -> 403 {"detail":"No write access to this book"}

Scopes and roles are not rivals. Scopes limit the token (an app the user installed may be allowed books:read only, although the user is an admin); roles limit the person. For the OAuth-side discussion of why the final decision belongs in the resource server see Scopes, Claims & Permissions.

Resource-Ownership Checks

Ownership is an ACL with exactly one entry, and the most common authorization bug when it is missing is an insecure direct object reference: a logged-in user changes someone else’s record by guessing an id. The rule belongs in a dependency that loads the object and checks it, so the endpoint receives only an object it is entitled to touch. The rbac_app.py listing above already contains it (get_owned_review): it requires the books:write scope, loads the review, answers 404 if it does not exist, and 403 unless the caller is the owner or an administrator.

DELETE /reviews/11   ada   -> 403 {"detail":"You can only change your own reviews"}
DELETE /reviews/99   ada   -> 404 {"detail":"Review not found"}
DELETE /reviews/10   ada   -> 204
DELETE /reviews/11   root  -> 204
DELETE /reviews/11   grace -> 403 {"detail":"Not enough permissions"}   (owner, but the token lacks books:write)

The last line shows the layers composing: grace owns review 11, but her token has no books:write scope, so the scope check stops the request before the ownership rule runs. Practical guidance:

  • Put the ownership check in the same dependency that loads the object, so endpoints cannot forget it. Writing if review.owner != user.username at the top of each handler is how one of them gets missed.

  • In SQL, prefer filtering in the query (WHERE id = :id AND owner_id = :uid) over load-then-compare when listing, so other users' rows are never fetched. See SQL Databases with SQLModel.

  • Return 404 for objects the caller must not even know about, 403 when existence is public but the action is not.

  • Use the same dependency for PATCH, PUT and DELETE, and test all three.

  • Raising HTTPException from a dependency is covered in Status Codes & Error Handling.

Using an External OpenID Connect Provider

Often the API should not issue tokens at all: a provider (Keycloak, Auth0, Microsoft Entra ID, Google, an internal single-sign-on service) authenticates users, and your FastAPI service is a resource server that only validates the access tokens it receives. How OpenID Connect extends OAuth 2.0 with an identity layer, the ID token, discovery and the openid scope is explained in OpenID Connect; delegating login to Google, GitHub or an enterprise IdP, and mapping the resulting identities to local accounts, in Social Login & Federation. Only the FastAPI half is here.

FastAPI offers three scheme classes for this situation. Their behaviour was checked against 0.142.2:

Class What it does

OpenIdConnect(openIdConnectUrl=…​)

Declares an openIdConnect scheme in OpenAPI pointing at the provider’s discovery document (/.well-known/openid-configuration). At request time it only reads the Authorization header and returns its whole value ("Bearer <token>", not the bare token); it does not validate anything. auto_error=False returns None instead of a 401 for optional authentication.

OAuth2AuthorizationCodeBearer(authorizationUrl, tokenUrl, scopes=…​)

Declares an OAuth2 authorizationCode flow with the same scopes catalogue as before, so Swagger UI can run the code flow against the provider. Returns the bare bearer token, with the same 401 behaviour.

HTTPBearer

Declares a plain http/bearer scheme. Returns the credentials object. Use it when the scheme is not worth describing in OpenAPI.

None of them verifies a token. Validation is your dependency’s job, and for a provider it means: fetch the provider’s public keys (the JWKS), select the one named by the token’s kid header, verify the signature with a pinned algorithm list, and check iss, aud and exp. The checklist and the attack classes it defends against (algorithm confusion, alg: none) are in JWT & JOSE. With PyJWT:

# oidc_app.py - accept access tokens issued by an external OpenID Connect provider
from functools import lru_cache
from typing import Annotated

import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OpenIdConnect
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict


class OidcSettings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="BOOKSHELF_OIDC_")

    issuer: str  # e.g. https://idp.example.com/realms/bookshelf
    audience: str  # this API's identifier at the provider
    jwks_uri: str  # from the discovery document
    discovery_url: str  # <issuer>/.well-known/openid-configuration
    algorithms: list[str] = ["RS256"]


@lru_cache
def get_oidc_settings() -> OidcSettings:
    return OidcSettings()


@lru_cache
def get_jwks_client() -> jwt.PyJWKClient:
    # Caches the key set (5 minutes by default) and re-fetches on an unknown `kid`.
    return jwt.PyJWKClient(get_oidc_settings().jwks_uri, cache_jwk_set=True, lifespan=300)


oidc_scheme = OpenIdConnect(openIdConnectUrl=get_oidc_settings().discovery_url)


class Principal(BaseModel):
    subject: str
    issuer: str
    scopes: list[str]


def get_principal(
    authorization: Annotated[str, Depends(oidc_scheme)],
    settings: Annotated[OidcSettings, Depends(get_oidc_settings)],
    jwks_client: Annotated[jwt.PyJWKClient, Depends(get_jwks_client)],
) -> Principal:  # plain `def`: PyJWKClient does blocking network I/O, so FastAPI runs it in a thread
    scheme, _, token = authorization.partition(" ")  # OpenIdConnect returns the whole header
    if scheme.lower() != "bearer" or not token:
        raise HTTPException(
            status.HTTP_401_UNAUTHORIZED, "Not authenticated", headers={"WWW-Authenticate": "Bearer"}
        )
    try:
        signing_key = jwks_client.get_signing_key_from_jwt(token)
        claims = jwt.decode(
            token,
            signing_key,
            algorithms=settings.algorithms,  # pinned: never read from the token header
            audience=settings.audience,
            issuer=settings.issuer,
            options={"require": ["exp", "iss", "aud", "sub"]},
        )
    except jwt.PyJWKClientConnectionError:
        raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "Identity provider unreachable") from None
    except jwt.PyJWTError:  # bad signature, expired, wrong aud/iss, unknown kid, ...
        raise HTTPException(
            status.HTTP_401_UNAUTHORIZED,
            "Invalid token",
            headers={"WWW-Authenticate": 'Bearer error="invalid_token"'},
        ) from None
    return Principal(
        subject=claims["sub"], issuer=claims["iss"], scopes=claims.get("scope", "").split()
    )


app = FastAPI(title="Bookshelf API")


@app.get("/reviews/mine")
def my_reviews(principal: Annotated[Principal, Depends(get_principal)]) -> dict:
    return {"who": principal.subject, "scopes": principal.scopes}

Points about this code:

  • OidcSettings holds the issuer, audience and endpoints from the environment (BOOKSHELF_OIDC_ISSUER and so on), taken from the provider’s discovery document. Nothing provider-specific is hard-coded.

  • PyJWKClient downloads the JWKS lazily and caches it (cache_jwk_set=True, lifespan=300 seconds); the single lru_cache`d instance is shared by all requests. `cache_keys=True additionally caches per-kid lookups. An unknown kid (after a key rotation) makes the client re-fetch once, subject to its cooldown_duration, so rotation does not need a restart.

  • get_principal is a plain def, so FastAPI runs it in a thread; PyJWKClient uses blocking I/O and would stall the event loop inside an async def (see Async & Concurrency).

  • algorithms=settings.algorithms is fixed by configuration and never taken from the token header. Passing the list is what prevents the HS256-with-the-public-key and alg: none forgeries.

  • options={"require": […​]} makes exp, iss, aud and sub mandatory; PyJWT only validates claims that are present unless told to require them.

  • Error mapping: a failure to reach the provider (PyJWKClientConnectionError) is a 503, not a 401; every other PyJWTError, including PyJWKClientError for an unknown kid, which is not a subclass of InvalidTokenError, is a 401. Catch PyJWTError if you want one handler for all of them.

  • The Principal is built from the token claims. Provider claim names differ: scope (string) is standard, Microsoft Entra ID uses scp for delegated permissions and roles for application roles, and Keycloak nests roles under realm_access and resource_access. Map them in one place.

  • An external sub is only unique per issuer; key local accounts by (iss, sub), never by e-mail address. Whether to create local users on first login or keep a separate user table is the account-model question treated in the social-login page linked above.

Validation can be tested without a network and without any key material in the repository: generate an RSA key pair in the test, publish its public half as a PyJWKSet, and override the get_jwks_client dependency (see Testing for dependency_overrides):

# test_oidc.py
import json
import time

import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi.testclient import TestClient

ISSUER = "https://idp.example.com/realms/bookshelf"


@pytest.fixture
def client(monkeypatch):
    monkeypatch.setenv("BOOKSHELF_OIDC_ISSUER", ISSUER)
    monkeypatch.setenv("BOOKSHELF_OIDC_AUDIENCE", "bookshelf-api")
    monkeypatch.setenv("BOOKSHELF_OIDC_JWKS_URI", f"{ISSUER}/protocol/openid-connect/certs")
    monkeypatch.setenv("BOOKSHELF_OIDC_DISCOVERY_URL", f"{ISSUER}/.well-known/openid-configuration")
    from oidc_app import app, get_jwks_client, get_oidc_settings

    get_oidc_settings.cache_clear()

    # The key pair exists only for this test run: no key material is stored anywhere.
    private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
    jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(private_key.public_key()))
    jwk.update({"kid": "test-key-1", "use": "sig", "alg": "RS256"})
    jwk_set = jwt.PyJWKSet.from_dict({"keys": [jwk]})

    class FakeJwksClient:
        def get_signing_key_from_jwt(self, token):
            kid = jwt.get_unverified_header(token).get("kid")
            for key in jwk_set.keys:
                if key.key_id == kid:
                    return key
            raise jwt.PyJWKClientError(f"Unable to find a signing key for {kid!r}")

    app.dependency_overrides[get_jwks_client] = FakeJwksClient
    c = TestClient(app)
    c.private_key = private_key
    yield c
    app.dependency_overrides.clear()


def make_token(client, kid="test-key-1", **overrides):
    now = int(time.time())
    claims = {"iss": ISSUER, "aud": "bookshelf-api", "sub": "user-123",
              "scope": "openid books:read", "iat": now, "exp": now + 300} | overrides
    return jwt.encode(claims, client.private_key, algorithm="RS256", headers={"kid": kid})


def get(client, token):
    return client.get("/reviews/mine", headers={"Authorization": f"Bearer {token}"})


def test_valid_token(client):
    r = get(client, make_token(client))
    assert r.status_code == 200
    assert r.json() == {"who": "user-123", "scopes": ["openid", "books:read"]}


@pytest.mark.parametrize(
    "overrides",
    [{"exp": 1}, {"aud": "another-api"}, {"iss": "https://evil.example.com"}],
    ids=["expired", "wrong-audience", "wrong-issuer"],
)
def test_bad_claims_are_rejected(client, overrides):
    assert get(client, make_token(client, **overrides)).status_code == 401


def test_unknown_kid_is_rejected(client):
    assert get(client, make_token(client, kid="rotated-key")).status_code == 401


def test_symmetric_and_unsigned_tokens_are_rejected(client):
    claims = {"iss": ISSUER, "aud": "bookshelf-api", "sub": "x", "exp": int(time.time()) + 300}
    hs256 = jwt.encode(claims, "x" * 32, algorithm="HS256", headers={"kid": "test-key-1"})
    unsigned = jwt.encode({"sub": "x"}, None, algorithm="none")
    assert get(client, hs256).status_code == 401
    assert get(client, unsigned).status_code == 401


def test_missing_header_is_401(client):
    assert client.get("/reviews/mine").status_code == 401
valid          200  {"who": "user-123", "scopes": ["openid", "books:read"]}
expired        401  {"detail": "Invalid token"}
wrong aud      401  {"detail": "Invalid token"}
wrong iss      401  {"detail": "Invalid token"}
unknown kid    401  {"detail": "Invalid token"}
missing exp    401  {"detail": "Invalid token"}
HS256 token    401  {"detail": "Invalid token"}
alg none       401  {"detail": "Invalid token"}
no header      401  {"detail": "Not authenticated"}

The OpenAPI document for this application contains only the declaration, which is all the OpenIdConnect class contributes:

{"OpenIdConnect": {"type": "openIdConnect", "openIdConnectUrl": "https://idp.example.com/realms/bookshelf/.well-known/openid-configuration"}}

If you want Swagger UI to run the login for you, declare OAuth2AuthorizationCodeBearer instead. This also shows scopes applied to a whole router with a decorator-level Security():

# authcode_app.py - let Swagger UI drive the authorization code flow
from typing import Annotated

from fastapi import APIRouter, Depends, FastAPI, Security
from fastapi.security import OAuth2AuthorizationCodeBearer

oauth2_scheme = OAuth2AuthorizationCodeBearer(
    authorizationUrl="https://idp.example.com/realms/bookshelf/protocol/openid-connect/auth",
    tokenUrl="https://idp.example.com/realms/bookshelf/protocol/openid-connect/token",
    scopes={"openid": "OpenID Connect", "books:read": "Read books"},
)


def require_token(token: Annotated[str, Depends(oauth2_scheme)]) -> str:
    return token  # validate it as in oidc_app.py


# Scopes on a whole router, applied through the decorator-level `dependencies`
router = APIRouter(
    prefix="/reviews", dependencies=[Security(require_token, scopes=["books:read"])]
)


@router.get("/")
def list_reviews() -> list[dict]:
    return []


app = FastAPI()
app.include_router(router)
{
  "OAuth2AuthorizationCodeBearer": {
    "type": "oauth2",
    "flows": {
      "authorizationCode": {
        "scopes": {"openid": "OpenID Connect", "books:read": "Read books"},
        "authorizationUrl": "https://idp.example.com/realms/bookshelf/protocol/openid-connect/auth",
        "tokenUrl": "https://idp.example.com/realms/bookshelf/protocol/openid-connect/token"
      }
    }
  }
}

Swagger UI’s code flow for public clients should use PKCE; the protocol is in Authorization Code & PKCE. Once validated, third-party scopes feed the same machinery as before: extend get_principal to compare principal.scopes with the SecurityScopes that Security() collects, exactly as get_current_user did for self-issued tokens.

Pitfalls

  • Trusting the requested scopes. Granting whatever the client asks for in form.scopes defeats the purpose. Intersect it with what the user is allowed.

  • Scopes in a token are a snapshot. Revoking a role or scope has no effect until the token expires. Keep access tokens short-lived (the examples use 15 minutes) and put anything revocation-sensitive (roles, ownership) in a database check.

  • Authenticating without authorizing. Depends(get_current_user) accepts any valid token; it does not check scopes. Use Security(…​, scopes=[…​]) to require them; the Depends spelling compiles and runs but silently enforces nothing.

  • Checking scopes in only some branches. Put the comparison in the one dependency every route reaches, as get_current_user does, rather than in each handler.

  • Expecting "any of". FastAPI requires all declared scopes. Model alternatives with a role, or write a dependency.

  • Mixing 403 and 401. 401 means "authenticate (again)", 403 means "authenticated, not permitted". Include WWW-Authenticate on every 401.

  • Revealing existence. A 403 for another user’s object tells the caller it exists; use 404 where that matters.

  • Role checks written as if user.role == "admin" in handlers. They scatter and drift; use permission-named dependencies.

  • Blocking JWKS fetches in async def. Use a plain def dependency or run the call in a thread.

  • Trusting alg and kid from the token. Pin algorithms; kid only selects among keys you already trust.

  • Skipping aud and iss. Without them a token meant for another API of the same provider is accepted.

  • Putting secrets in source. Signing keys come from settings; a copied example key in a repository is a compromised key.

Where Older Guides Are Outdated, and What Changed Recently

  • Missing credentials were 403. Until 0.122.0 (2025-11-24) the security classes answered 403 Forbidden when the Authorization header was absent; they now answer 401 Unauthorized with WWW-Authenticate, as HTTP specifies. The how-to "Use Old 403 Authentication Error Status Codes" explains how to restore the old behaviour for clients that depend on it. Tests copied from older tutorials that assert 403 fail on current releases.

  • python-jose and passlib. Material from 2021-2023 signs tokens with python-jose and hashes with `passlib’s bcrypt. The official tutorial moved to PyJWT in 0.111.1 (2024-07-14) and to pwdlib with Argon2 in 0.118.0 (2025-09-29); both are what this page uses. Details in OAuth2 Password Flow & JWT.

  • datetime.utcnow(). Deprecated since Python 3.12; the examples use timezone-aware datetime.now(UTC).

  • Authorization stops at "is the user logged in". Most books and blog posts stop after the login form and an is_admin flag. Scopes, per-object checks and external providers are the parts that real services need.

  • Default-value style. Older code writes current_user: User = Depends(get_current_user) and Security(get_current_user, scopes=[…​]) as default values. Annotated has been recommended since 0.95.0 and is used throughout; see Python Types & Annotated.

  • Pydantic v1 models in user stores. .dict() and class Config examples are gone; current code uses model_dump() and model_config. See What’s Changed & Migration.

  • Dependency scopes of Depends with use_cache. In 0.121.0 (2025-11-03) dependencies gained a scope ("request" or "function") for yield dependencies; it concerns resource lifetime, not OAuth2 scopes, despite the shared word. See Dependencies with yield.