This came out of a coaching project: a FastAPI service backing a geotechnical database. We use Azure Entra ID for authentication, but we needed role-based access control (RBAC) granularly per endpoint and across different user roles. It turned into an interesting sprint where we designed it so that role changes did not require code changes.

I've seen FastAPI auth examples hardcode a policy like: if "Admin" not in user.roles: raise 403, copy-pasted into every route. It works until the day someone asks "can you let Finance approve expenses too?" and now you are editing code and redeploying to answer a question that is really about data.

I built a small demo that does the opposite: Azure Entra ID handles authentication and which roles a user has, but which roles each endpoint demands lives in a database table. An admin re-points access at runtime through an endpoint that is itself protected by the same mechanism.

Full code, setup steps, and tests: bbelderbos/azure_fastapi_rbac_demo.

The one decision that matters

The roles a user carries come from Entra, baked into the JWT roles claim. That part is fixed. The interesting choice is where you store the mapping from endpoint to required roles.

Put it in code and every policy change is a deploy. Put it in a table and it becomes editable state. It is the 12-factor instinct applied to authorization: anything that varies on a schedule you do not control belongs outside the code, as data:

from sqlalchemy import Column, JSON
from sqlmodel import Field, SQLModel


class EndpointPermission(SQLModel, table=True):
    endpoint_key: str = Field(primary_key=True)
    roles: list[str] = Field(default_factory=list, sa_column=Column(JSON))

One row per protected endpoint. The demo seeds three on startup, which is the whole policy at a glance:

SEED = {
    "expenses:read": ["Viewer", "Approver", "Admin"],
    "expenses:approve": ["Approver", "Admin"],
    "admin:permissions": ["Admin"],
}

So require("expenses:read") lets any of the three roles through, while expenses:approve drops Viewer. The only thing hardcoded in a route is its key string. The guard reads the rest from the table:

def require(endpoint_key: str) -> Callable[..., Awaitable[User]]:
    async def checker(
        user: User = Depends(azure_scheme),
        session: Session = Depends(get_session),
    ) -> User:
        perm = session.get(EndpointPermission, endpoint_key)
        allowed = set(perm.roles) if perm is not None else set()
        if not allowed.intersection(set(user.roles)):
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="You do not have permission to access this endpoint.",
            )
        return user

    return checker

azure_scheme is fastapi-azure-auth's SingleTenantAzureAuthorizationCodeBearer. It validates the token and hands back a User with .roles already populated. The guard does one set intersection. That is the entire authorization model.

Routes read clean, the key string is the only literal:

@app.get("/expenses")
async def list_expenses(user: Annotated[User, Depends(require("expenses:read"))]):
    return {"expenses": ["expense1", "expense2"], "user": user.name}

And it applies to itself as well. Editing the policy table is itself an endpoint guarded by require("admin:permissions"), so an Admin can change who approves expenses without touching code. Two guards keep that power from locking everyone out: roles can't be empty, and Admin can't remove itself from the admin key.

def locks_out_admins(endpoint_key: str, roles: list[str]) -> bool:
    return endpoint_key == "admin:permissions" and "Admin" not in roles


@app.put("/admin/permissions")
async def set_permission(endpoint_key: str, roles: list[str], ...):
    if not roles:
        raise HTTPException(422, "roles must be non-empty")
    if locks_out_admins(endpoint_key, roles):
        raise HTTPException(422, "cannot remove Admin from admin:permissions")
    ...  # upsert the row, return it

Two Azure traps that cost an afternoon

The full setup (two app registrations, the user_impersonation scope, defining the app roles, assigning users) is in the repo README. Two gotchas are worth pulling out here, because neither errors in a way that points back at the cause:

v2 tokens, or everything 401s. fastapi-azure-auth validates against the v2 token endpoint. It expects aud to be the bare API client-id GUID and iss to end in /v2.0. By default a fresh registration mints v1 tokens (aud = api://<client-id>, iss = sts.windows.net/...), so validation rejects a token that looks perfectly valid at a glance, with invalid_token: "Token contains invalid claims". The fix is one line in the backend API's Manifest:

{ "api": { "requestedAccessTokenVersion": 2 } }

Mint a token, paste it into jwt.ms, and confirm ver reads 2.0.

Direct assignment only. When you assign test users to roles, only direct assignment flows into the roles claim; nested group membership does not. The call succeeds, the token validates, and the user simply has no roles, which is its own silent time sink.

Testing without Azure

Because authorization is a dependency, you swap it in tests. Override azure_scheme with a fake user and get_session with an in-memory SQLite session, and the entire role matrix is unit-testable with no network and no tenant:

def client_as(engine: Engine, *roles: str) -> TestClient:
    def fake_user():
        return SimpleNamespace(roles=list(roles), name="tester")

    def session_override():  # in-memory SQLite
        with Session(engine) as s:
            yield s

    app.dependency_overrides[azure_scheme] = fake_user
    app.dependency_overrides[get_session] = session_override
    return TestClient(app)


def test_viewer_cannot_approve(engine):
    assert client_as(engine, "Viewer").post("/expenses/1/approve").status_code == 403

And because the policy lives in a row, the headline claim is itself a test: an Admin rewrites the row and the same request flips from allowed to forbidden, no redeploy:

def test_repoint_revokes_access(engine):
    assert client_as(engine, "Approver").post("/expenses/1/approve").status_code == 200
    client_as(engine, "Admin").put(  # drop Approver from expenses:approve
        "/admin/permissions",
        params={"endpoint_key": "expenses:approve"}, json=["Admin"],
    )
    assert client_as(engine, "Approver").post("/expenses/1/approve").status_code == 403

This is the same Depends paying off again. In production it lets an Admin edit the rules in a table. In tests it lets you swap that table for in-memory SQLite and the real login for a fake user, one line each. One design choice, two wins.

When you reach for an if role == check, ask whether the thing you are encoding is logic or policy. Logic belongs in code. Policy tends to want to change on a schedule you do not control, and the table was cheaper than you think.