In this article
June 3, 2026
June 3, 2026

How to implement RBAC authorization in Python APIs with WorkOS

Set up roles and permissions, verify session JWTs, and protect your FastAPI routes with dependency injection.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Role-based access control (RBAC) is one of those problems that looks solved until it isn't. Most tutorials show you how to check a role and return a 403. What they skip is the architecture that keeps that logic from rotting quietly across hundreds of endpoints over time.

This tutorial uses FastAPI and WorkOS to build RBAC the way it should work in production: a verified JWT carries claims into your API, a thin enforcement layer asks "is this allowed?", and a centralized policy layer answers. WorkOS handles the hard parts of managing roles, permissions, and org-scoped assignments; your app just enforces what it's told.

By the end, you'll have a multi-tenant API where:

  • Routes are protected using FastAPI dependency injection.
  • Permissions are checked against WorkOS-issued session tokens.
  • Org-scoped roles let different tenants define their own access levels without touching your code.

Prerequisites

  • Python 3.10 or later
  • A WorkOS account (free up to 1 million monthly active users)
  • Basic familiarity with FastAPI and async Python

Install dependencies:

  
pip install fastapi uvicorn python-jose httpx workos
  

Part 1: Setting up WorkOS RBAC

1.1 Install the WorkOS CLI

You can create and manage roles and permissions through the WorkOS dashboard or the API, but the CLI is faster and gives you something you can commit to version control. Install it with:

  
npx workos@latest install
  

Then authenticate:

  
workos auth login
  

1.2 Define permissions

Permissions in WorkOS are immutable slugs that represent specific actions in your application. Think of them as the atoms of your authorization model. For a typical content API, you might define:

  
read:document
write:document
delete:document
manage:users
manage:settings
  

Create them from the terminal:

  
workos permission create --name "Read document" --slug "read:document"
workos permission create --name "Write document" --slug "write:document"
workos permission create --name "Delete document" --slug "delete:document"
  

Or verify what's already provisioned:

  
workos permission list
  

1.3 Define roles

Roles are bundles of permissions. Create them and assign permissions to each:

Role read:document write:document delete:document manage:users manage:settings
viewer
editor
admin
  
workos role create --name "Viewer" --slug "viewer"
workos role create --name "Editor" --slug "editor"
workos role create --name "Admin" --slug "admin"
  

WorkOS lets you set a default role (usually viewer) that every new org member gets automatically.

1.4 Declarative provisioning with seed

For team environments or CI pipelines, the CLI's seed command lets you define your entire permissions and roles model in a YAML file and provision it in one command. This is worth setting up early: it gives you a single file that documents your authorization model and can be checked into version control.

  
# workos-seed.yaml
permissions:
  - name: "Read document"
    slug: "read:document"
  - name: "Write document"
    slug: "write:document"
  - name: "Delete document"
    slug: "delete:document"
  - name: "Manage users"
    slug: "manage:users"
  - name: "Manage settings"
    slug: "manage:settings"

roles:
  - name: "Viewer"
    slug: "viewer"
    permissions: ["read:document"]
  - name: "Editor"
    slug: "editor"
    permissions: ["read:document", "write:document"]
  - name: "Admin"
    slug: "admin"
    permissions: ["read:document", "write:document", "delete:document", "manage:users", "manage:settings"]
  
  
workos seed
  

The CLI tracks state so you can tear everything down cleanly with --clean if you need to reset a sandbox environment.

1.5 Understand org-scoped roles

This is where WorkOS earns its place in B2B APIs. Your default roles apply across all tenants, but any customer organization can create its own custom roles scoped to their membership. A "Finance Admin" in Org A has no effect on Org B. Your code doesn't need to know about any of this: the correct roles and permissions for a given user-in-org combination are embedded in their session JWT. You just read them.

1.6 Configure your environment

  
# .env
WORKOS_API_KEY=sk_live_...
WORKOS_CLIENT_ID=client_...
WORKOS_REDIRECT_URI=http://localhost:8000/auth/callback
  

Part 2: The enforcement layer

The enforcement layer is a thin piece of code that does one thing: asks "is this allowed?" and raises an exception if not. In FastAPI, this maps naturally onto dependency injection: you define a dependency that verifies a token and extracts claims, then inject it into any route you want to protect. The result is declarative, testable, and easy to audit. It should also stay stable even as your policy evolves from RBAC toward ABAC (attribute-based access control) or relationship-based models later.

2.1 Token verification

Authorization decisions are only as good as their inputs, which means token verification has to come first. Reading claims from an unverified token is one of the most common and quietest authorization bugs in production APIs. Always validate fully before trusting anything in the payload.

Create a auth.py module that verifies the WorkOS session token and extracts claims:

  
# auth.py
import os
import workos
from fastapi import HTTPException, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

workos_client = workos.WorkOS(api_key=os.environ["WORKOS_API_KEY"])
bearer = HTTPBearer()

async def get_session(
    credentials: HTTPAuthorizationCredentials = Security(bearer),
) -> dict:
    """
    Verify the WorkOS session token and return its claims.
    Raises 401 if the token is invalid or expired.
    """
    token = credentials.credentials
    try:
        session = workos_client.user_management.authenticate_with_session_cookie(
            session_data=token,
            cookie_password=os.environ["WORKOS_COOKIE_PASSWORD"],
        )
    except Exception:
        raise HTTPException(status_code=401, detail="Invalid or expired session")

    return {
        "user_id": session.user.id,
        "org_id": session.organization_id,
        "role": session.role,
        "permissions": session.permissions or [],
    }
  

Notice what this function does not do: it does not make an authorization decision. It only answers "is this token valid, and who does it belong to?" Authorization is a separate concern.

2.2 The permission checker

Create an authorize.py module that houses the policy layer:

  
# authorize.py
from fastapi import HTTPException


def require_permission(permission: str):
    """
    Returns a FastAPI dependency that checks for a specific permission slug.
    Usage: Depends(require_permission("write:document"))
    """
    async def _check(session: dict = Depends(get_session)):
        if permission not in session["permissions"]:
            raise HTTPException(
                status_code=403,
                detail=f"Missing required permission: {permission}",
            )
        return session
    return _check
  

This is the key architectural move: enforcement is a factory that returns a dependency. Your routes never touch role names or permission strings directly in business logic; they declare what they need and the dependency system handles the rest.

Part 3: Protecting routes

With the enforcement layer in place, protecting a route is a single line:

  
# main.py
from fastapi import FastAPI, Depends
from auth import get_session
from authorize import require_permission

app = FastAPI()


@app.get("/documents")
async def list_documents(session=Depends(require_permission("read:document"))):
    """Anyone with read:document can list documents."""
    return {"documents": [], "org_id": session["org_id"]}


@app.post("/documents")
async def create_document(session=Depends(require_permission("write:document"))):
    """Only editors and admins can create documents."""
    return {"created": True, "user_id": session["user_id"]}


@app.delete("/documents/{doc_id}")
async def delete_document(
    doc_id: str,
    session=Depends(require_permission("delete:document")),
):
    """Only admins can delete documents."""
    return {"deleted": doc_id}


@app.get("/health")
async def health():
    """Public endpoint, no auth required."""
    return {"status": "ok"}
  

The pattern is explicit and auditable. Every protected route states its required permission. A code reviewer can scan the file and immediately understand the access model without reading any middleware or checking any other file.

Part 4: Org-scoped enforcement

For multi-tenant APIs, you often need to ensure that a user can only act within their own organization, not just check that they have the right permission. Add a tenant guard to the authorize module:

  
# authorize.py (extended)
from fastapi import HTTPException, Depends
from auth import get_session


def require_permission(permission: str, enforce_org: bool = True):
    """
    Checks for a permission slug and optionally verifies the request
    targets the user's own organization.
    """
    async def _check(
        org_id: str = None,  # passed from path or query param
        session: dict = Depends(get_session),
    ):
        # Check permission
        if permission not in session["permissions"]:
            raise HTTPException(status_code=403, detail=f"Missing permission: {permission}")

        # Check org boundary
        if enforce_org and org_id and session["org_id"] != org_id:
            raise HTTPException(
                status_code=403,
                detail="Access denied: cross-organization requests are not allowed",
            )

        return session
    return _check
  

Now a user with write:document in Org A cannot write documents in Org B, even if they somehow obtain a valid token for the wrong org. The org boundary is enforced at the policy layer, not scattered across individual handlers.

Part 5: Assigning roles via the WorkOS API

WorkOS manages role assignments through organization memberships. When a user joins an org, they receive the default role. To elevate them, call the organization memberships API:

  
import workos
import os

client = workos.WorkOS(api_key=os.environ["WORKOS_API_KEY"])

def assign_role(user_id: str, org_id: str, role_slug: str):
    """
    Assign a role to a user within a specific organization.
    Call this from your admin endpoints, not from user-facing code.
    """
    membership = client.user_management.get_organization_membership(
        user_id=user_id,
        organization_id=org_id,
    )
    client.user_management.update_organization_membership(
        organization_membership_id=membership.id,
        role_slug=role_slug,
    )
  

Role changes are reflected in the next session token the user receives. For immediate enforcement, you can revoke the current session and force re-authentication, or design your app to re-fetch the session on short intervals.

The API is one of several ways to assign roles in WorkOS. Depending on your setup, you might use:

  • Directory sync: role assignments can be driven automatically from a customer's corporate directory (Okta, Azure AD, Google Workspace). When a user's group membership changes in the IdP, their WorkOS role updates to match.
  • SSO group mapping: for standalone SSO, organization administrators can map IdP groups to roles directly, so role assignment is handled by the customer's IT team rather than your application.
  • WorkOS dashboard: useful for manual assignments during development or for support workflows where an admin needs to adjust a specific user's access without writing code.
  • WorkOS CLI: workos role assign works well for scripted provisioning or seeding test environments.

Part 6: Common mistakes to avoid

  • Checking roles instead of permissions. Checking if session["role"] == "admin" couples your code to role names. When you add a "Super Admin" role later, you have to hunt down every == "admin" check in the codebase. Check permissions instead; they're stable.
  • Authorizing off unverified tokens. Always run the full token verification before reading any claims. A token that passes signature verification but has the wrong audience or issuer can still carry forged claims.
  • Mixing authorization with business logic. Keep require_permission in your dependency chain, not inside handler functions. The moment you write if "delete:document" not in session["permissions"] inside a route body, you've broken the audit trail.
  • Assuming all users have the same roles across orgs. A user can be an admin in one org and a viewer in another. The session JWT is scoped to the current org context. Always read org-scoped claims, not global user-level roles.

Part 7: Testing your authorization layer

Test authorization in isolation by mocking the get_session dependency. This lets you verify that your routes return the right status codes for every permission combination without touching the WorkOS API:

  
# test_routes.py
import pytest
from fastapi.testclient import TestClient
from main import app
from auth import get_session


def make_session(permissions: list[str]) -> dict:
    return {
        "user_id": "user_123",
        "org_id": "org_456",
        "role": "custom",
        "permissions": permissions,
    }


@pytest.fixture
def viewer_client():
    app.dependency_overrides[get_session] = lambda: make_session(["read:document"])
    yield TestClient(app)
    app.dependency_overrides.clear()


@pytest.fixture
def editor_client():
    app.dependency_overrides[get_session] = lambda: make_session(
        ["read:document", "write:document"]
    )
    yield TestClient(app)
    app.dependency_overrides.clear()


def test_viewer_can_read(viewer_client):
    response = viewer_client.get("/documents")
    assert response.status_code == 200


def test_viewer_cannot_write(viewer_client):
    response = viewer_client.post("/documents")
    assert response.status_code == 403


def test_editor_can_write(editor_client):
    response = editor_client.post("/documents")
    assert response.status_code == 200
  

Write one test per permission boundary. If a future developer accidentally removes a require_permission dependency, the test suite catches it immediately.

When RBAC isn't enough: Fine-grained authorization

The pattern in this tutorial handles the majority of B2B access control cases well. But as your product grows, you'll likely hit scenarios that tenant-wide roles can't express cleanly: a user who is an editor in one workspace but a viewer in another, a project that inherits permissions from a parent organization, or a customer who needs to grant access to a specific resource without touching anything else.

That's where WorkOS FGA (Fine-Grained Authorization) comes in. FGA extends the RBAC system you've already set up here, keeping the same mental model of roles, permissions, and assignments, but making resources and their hierarchy first-class. A workspace admin automatically gets access to all projects and apps within that workspace without needing separate role assignments at each level. Permissions flow down the hierarchy; you assign a role once.

The adoption path is incremental and requires no migration. Your existing roles and org memberships keep working exactly as they do today. You start by defining resource types in the WorkOS dashboard to mirror your product structure, register resource instances as they're created at runtime, and introduce resource-scoped roles like workspace-admin or project-editor wherever you need them. The enforcement model in your FastAPI app stays largely the same: for org-wide checks, you continue reading from the session JWT; for resource-scoped checks, you call the WorkOS Authorization API.

If you anticipate complex, nested resource structures down the line, it's worth reading the FGA documentation before your authorization model gets too entrenched.

Wrapping up

The pattern you've built here separates three things that most implementations collapse into one:

  1. Verification (did WorkOS issue this token, and is it still valid?)
  2. Enforcement (does this route require a specific permission?)
  3. Policy (what permissions does this user-in-org have?)

WorkOS owns layer three entirely. Your app only has to handle layers one and two, and FastAPI's dependency system makes both of those clean and auditable.

As your authorization model grows, you can evolve the policy layer toward attribute-based rules or relationship checks without touching the enforcement code or the route definitions. The enforcement layer stays stable because it only asks "is this allowed?" It never needs to know why.

Ready to add RBAC to your Python API? Sign up for WorkOS and get started for free. No credit card required, up to 1 million monthly active users for free.