An application usually has different types of users: customers, sellers, administrators. Each should reach only where they are supposed to. Manual if checks inside every handler are chaos: the logic spreads across the whole codebase, and it is easy to miss an unprotected endpoint.
RBAC (Role-Based Access Control) solves this systematically: a user's role determines which endpoints they can access at all.
Where the user's role comes from
When a user logs in through Keycloak or another OAuth2 provider, the server issues them a JWT. Inside the token, in the realm_access.roles section, a list of roles is stored:
{
"sub": "user-42",
"realm_access": {
"roles": ["customer", "loyalty-member"]
}
}
Standard OAuth2 (without Keycloak) puts roles in the scope field as a space-separated string: "scope": "customer read:orders".
The application's job is to extract these roles from the token on every request and make them available in the handler.
Principal: the object that represents the user
Instead of working directly with the raw dictionary from the JWT, it is convenient to wrap the user data in a dedicated dataclass, Principal. It holds the user identifier (sub) and their list of roles:
# adapters/in/http/security.py
from dataclasses import dataclass
from typing import Any
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
import jwt
from jwt import PyJWKClient
from config import settings
_bearer = HTTPBearer()
_jwks = PyJWKClient(settings.jwks_uri, cache_keys=True, lifespan=300)
@dataclass(frozen=True)
class Principal:
sub: str
roles: list[str]
def is_admin(self) -> bool:
return "admin" in self.roles
def _roles(claims: dict[str, Any]) -> list[str]:
realm = claims.get("realm_access", {})
if isinstance(realm, dict) and "roles" in realm:
return realm["roles"]
scope = claims.get("scope", "")
return scope.split() if scope else []
async def principal(
creds: HTTPAuthorizationCredentials = Depends(_bearer),
) -> Principal:
token = creds.credentials
try:
key = _jwks.get_signing_key_from_jwt(token).key
claims = jwt.decode(
token, key, algorithms=["RS256"],
audience=settings.audience, issuer=settings.issuer,
)
except jwt.PyJWTError as e:
raise HTTPException(status_code=401, detail="invalid token") from e
return Principal(sub=claims["sub"], roles=_roles(claims))
The _roles function can read roles in both formats: Keycloak and standard OAuth2. It always returns a list[str], so the rest of the code does not know where the roles came from.
The principal dependency verifies the token's signature via JWKS (the provider's public keys), checks the audience and issuer, and only then assembles the Principal. An invalid or expired token → 401 Unauthorized.
require_roles: protecting an endpoint
Knowing how to obtain a Principal, it is easy to write a dependency that checks roles:
def require_roles(*roles: str):
async def dep(p: Principal = Depends(principal)) -> Principal:
if not set(roles) & set(p.roles):
raise HTTPException(status_code=403, detail="forbidden")
return p
return dep
If the user has none of the listed roles → 403 Forbidden. The semantics are OR: matching at least one role is enough.
Usage in a router:
# adapters/in/http/order_router.py
from fastapi import APIRouter, Depends
from adapters.in.http.security import Principal, require_roles
from application.create_order_handler import CreateOrderCommand, CreateOrderHandler
from application.get_order_handler import GetOrderByIdQuery, GetOrderHandler
from application.cancel_order_handler import CancelOrderCommand, CancelOrderHandler
router = APIRouter(prefix="/orders")
@router.post("")
async def create_order(
request: CreateOrderRequest,
p: Principal = Depends(require_roles("customer")),
handler: CreateOrderHandler = Depends(),
):
return handler.handle(CreateOrderCommand(customer_id=p.sub, **request.model_dump()))
@router.get("/{order_id}")
async def get_order(
order_id: str,
p: Principal = Depends(require_roles("customer", "admin")),
handler: GetOrderHandler = Depends(),
):
return handler.handle(GetOrderByIdQuery(order_id=order_id, principal=p))
@router.post("/{order_id}/cancel")
async def cancel_order(
order_id: str,
p: Principal = Depends(require_roles("customer", "admin")),
handler: CancelOrderHandler = Depends(),
):
return handler.handle(CancelOrderCommand(order_id=order_id, principal=p))
Important: an endpoint without Depends(require_roles(...)) is open to anyone with a valid token — even if it sits in an /admin/ namespace. Having a router with an /admin prefix protects nothing by itself. Each endpoint declares its roles explicitly.
How many roles you need
A good starting point is four roles:
| Role | Who | What they do |
|---|---|---|
customer | End user | Creates and reads their own orders, pays |
seller | Marketplace seller | Manages their own products, sees orders for their products |
admin | Internal user | Full access, actions are logged |
system | Another service | Inter-service calls (Client Credentials or mTLS) |
The more roles, the more complex the system. A common trap is introducing a new role where an attribute is needed:
- "premium-customer" is not a role but a user attribute (
customer.is_premium), checked in the handler. - "junior-admin who only reads" is not a role but a permissions system, which is more complex and rarely needed.
- "B2B clients see a different catalog" is most likely an entirely different service.
A new role is needed when a fundamentally new type of participant appears in the system, not when a new feature appears.
RBAC and ABAC — two different questions
RBAC answers the question: "may a user with the customer role access GET /orders/{order_id} at all?"
RBAC does not answer the question: "is order-12345 this particular user's order?" That is already ABAC (Attribute-Based Access Control) — you need to load the order from the database and compare order.customer_id with principal.sub.
# RBAC — at the endpoint level: who is even allowed
@router.post("/{order_id}/cancel")
async def cancel_order(
order_id: str,
p: Principal = Depends(require_roles("customer", "admin")),
handler: CancelOrderHandler = Depends(),
):
return handler.handle(CancelOrderCommand(order_id=order_id, principal=p))
# ABAC — at the business-logic level: whether this order is really theirs
class CancelOrderHandler:
def handle(self, cmd: CancelOrderCommand) -> Order:
order = self._repo.find_by_id_for_update(cmd.order_id)
if not cmd.principal.is_admin() and order.customer_id != cmd.principal.sub:
raise ForbiddenError("order does not belong to current user")
order.cancel()
return self._repo.save(order)
Both layers are needed: without RBAC any authenticated user tries to reach other users' resources; without ABAC a customer with a valid token gets someone else's data.
Tests
Authorization is worth checking with dedicated tests — do not rely on "the business logic will fail anyway":
# tests/test_order_router_auth.py
import pytest
from httpx import AsyncClient
@pytest.mark.anyio
async def test_create_order_requires_customer_role(client: AsyncClient, seller_token: str):
resp = await client.post(
"/orders",
json={"product_id": "prod-1", "quantity": 2},
headers={"Authorization": f"Bearer {seller_token}"},
)
assert resp.status_code == 403
@pytest.mark.anyio
async def test_create_order_without_token_returns_401(client: AsyncClient):
resp = await client.post("/orders", json={"product_id": "prod-1", "quantity": 2})
assert resp.status_code == 401
401 — no token or it is invalid. 403 — the token is valid but the role does not fit. These codes must not be confused: 401 means "log in", 403 means "you do not have the right".
Common mistakes
An endpoint without a role check. If you forget Depends(require_roles(...)), the endpoint is open to anyone with a token. This is not "restricted access", it is a public endpoint behind a token.
Too many roles. Ten roles are a sign that RBAC is trying to do the work of attributes or permissions. Keep roles minimal and move the details into business logic.
Ownership checks via roles. if "customer" in p.roles and order.customer_id == p.sub is ABAC written in the router. It belongs in the handler.
Roles scattered as strings across the code. Constants or an Enum instead of strings in several places — otherwise a typo will go unnoticed.
In short
- RBAC is the first layer of authorization: it determines which roles have access to an endpoint.
- Roles come from the JWT:
realm_access.roles(Keycloak) orscope(standard OAuth2). - One
Principalclass holdssubandroles; one_roles()function can read both formats. require_roles(*roles)is a FastAPI dependency with OR semantics: one matching role is enough.- Each endpoint declares its roles explicitly; the absence of
require_roles= open access. - Four basic roles:
customer,seller,admin,system. A new role is rarely needed. - RBAC answers "who may"; ABAC — "whose resource". Both layers are needed.
401— no/invalid token;403— the required role is missing.
Further reading
- JWT validation in FastAPI — how signature verification works and why a JWKS cache is needed.
- ABAC: resource ownership — the next layer after RBAC.
- Service-to-service authorization — the
systemrole, Client Credentials, mTLS.