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 |
"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 |
|---|---|
|
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. |
|
The |
|
Like |
|
A special parameter type: declare it in a dependency and FastAPI fills it with the scopes required by the chain of
|
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}
Source: FastAPI: OAuth2 scopes
What each part does:
-
The scope catalogue (
SCOPES) is passed toOAuth2PasswordBearer(scopes=…). It is documentation for clients. -
/tokendecides the grant.form.scopesis only a request. The server intersects it withuser.scopes, so a client asking forbooks:deleteon 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 responsescopefield, 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_userruns once perSecurity()declaration and has two jobs: authenticate (validate signature, expiry, and thatsubstill exists) and authorize (every required scope must be in the token). Missing scope is403; a bad or missing token is401. -
WWW-Authenticate: Bearer scope="…"tells the client which scopes the endpoint needed, as RFC 6750 describes. -
CurrentUseris a reusableAnnotatedalias, so "requires themescope" 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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
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
securityentry lists the union that reaches the security scheme. For the/debug/scopesroute 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
Source: FastAPI: Testing
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]
Source: FastAPI: Classes as Dependencies
How the three differ:
-
Admin flag (
require_admin) is a dependency that depends on another dependency (CurrentUser) and adds one check. Composition throughAnnotatedaliases (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); onlyROLE_PERMISSIONSknows which roles carry it.RequirePermissionis a class withcall, 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 answers404rather than403for 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.usernameat 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
404for objects the caller must not even know about,403when existence is public but the action is not. -
Use the same dependency for PATCH, PUT and DELETE, and test all three.
-
Raising
HTTPExceptionfrom 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 |
|---|---|
|
Declares an |
|
Declares an OAuth2 |
|
Declares a plain |
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}
Source: FastAPI: Security Tools and PyJWT: Usage Examples (Retrieve RSA signing keys from a JWKS endpoint)
Points about this code:
-
OidcSettingsholds the issuer, audience and endpoints from the environment (BOOKSHELF_OIDC_ISSUERand so on), taken from the provider’s discovery document. Nothing provider-specific is hard-coded. -
PyJWKClientdownloads the JWKS lazily and caches it (cache_jwk_set=True,lifespan=300seconds); the singlelru_cache`d instance is shared by all requests. `cache_keys=Trueadditionally caches per-kidlookups. An unknownkid(after a key rotation) makes the client re-fetch once, subject to itscooldown_duration, so rotation does not need a restart. -
get_principalis a plaindef, so FastAPI runs it in a thread;PyJWKClientuses blocking I/O and would stall the event loop inside anasync def(see Async & Concurrency). -
algorithms=settings.algorithmsis fixed by configuration and never taken from the token header. Passing the list is what prevents the HS256-with-the-public-key andalg: noneforgeries. -
options={"require": […]}makesexp,iss,audandsubmandatory; PyJWT only validates claims that are present unless told to require them. -
Error mapping: a failure to reach the provider (
PyJWKClientConnectionError) is a503, not a401; every otherPyJWTError, includingPyJWKClientErrorfor an unknownkid, which is not a subclass ofInvalidTokenError, is a401. CatchPyJWTErrorif you want one handler for all of them. -
The
Principalis built from the token claims. Provider claim names differ:scope(string) is standard, Microsoft Entra ID usesscpfor delegated permissions androlesfor application roles, and Keycloak nests roles underrealm_accessandresource_access. Map them in one place. -
An external
subis 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)
Source: FastAPI: Security Tools
{
"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.scopesdefeats 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. UseSecurity(…, scopes=[…])to require them; theDependsspelling 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_userdoes, rather than in each handler. -
Expecting "any of". FastAPI requires all declared scopes. Model alternatives with a role, or write a dependency.
-
Mixing
403and401.401means "authenticate (again)",403means "authenticated, not permitted". IncludeWWW-Authenticateon every401. -
Revealing existence. A
403for another user’s object tells the caller it exists; use404where 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 plaindefdependency or run the call in a thread. -
Trusting
algandkidfrom the token. Pin algorithms;kidonly selects among keys you already trust. -
Skipping
audandiss. 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 answered403 Forbiddenwhen theAuthorizationheader was absent; they now answer401 UnauthorizedwithWWW-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 assert403fail on current releases. -
python-joseandpasslib. Material from 2021-2023 signs tokens withpython-joseand 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-awaredatetime.now(UTC). -
Authorization stops at "is the user logged in". Most books and blog posts stop after the login form and an
is_adminflag. 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)andSecurity(get_current_user, scopes=[…])as default values.Annotatedhas been recommended since 0.95.0 and is used throughout; see Python Types & Annotated. -
Pydantic v1 models in user stores.
.dict()andclass Configexamples are gone; current code usesmodel_dump()andmodel_config. See What’s Changed & Migration. -
Dependency scopes of
Dependswithuse_cache. In 0.121.0 (2025-11-03) dependencies gained ascope("request"or"function") foryielddependencies; it concerns resource lifetime, not OAuth2 scopes, despite the shared word. See Dependencies withyield.