← Back to the section

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:

RoleWhoWhat they do
customerEnd userCreates and reads their own orders, pays
sellerMarketplace sellerManages their own products, sees orders for their products
adminInternal userFull access, actions are logged
systemAnother serviceInter-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) or scope (standard OAuth2).
  • One Principal class holds sub and roles; 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