
How to Add Authentication to a Python Backend - Part 2
Part 2 of 3. Start with How to Add Authentication to a Python Backend.
This is the second part of a comprehensive guide on adding Clerk authentication to Python backends. In Part 1, we covered the architectural theory and Clerk Dashboard setup.
Here, we implement the backend authentication layer. We provide complete integrations for the three most popular Python web frameworks: FastAPI, Flask, and Django (via Django REST Framework). Each framework section builds a robust authentication pipeline covering project setup, dependency/middleware design, endpoint protection, and Role-Based Access Control (RBAC).
Adding Clerk authentication to FastAPI
This is the biggest section. FastAPI's dependency injection system is the idiomatic place for authentication, and the modern Annotated[X, Depends(dep)] syntax makes it read cleanly. If you're on an older FastAPI tutorial using = Depends() in default parameters, the pattern here is the current one.
Project setup from scratch
uv init python-backend-auth
cd python-backend-auth
uv add fastapi "uvicorn[standard]" clerk-backend-api pydantic-settings python-dotenvMinimal app/main.py:
from fastapi import FastAPI
app = FastAPI(title="Python Backend Auth")
@app.get("/health")
def health():
return {"status": "ok"}Run the dev server:
uv run uvicorn app.main:app --reload --port 8000Hit http://localhost:8000/health and you should see {"status":"ok"}. Everything else in this section builds on this skeleton.
For production, don't run uvicorn --reload. Use Gunicorn as a process manager with Uvicorn workers — uv add gunicorn uvicorn-worker, then gunicorn -k uvicorn_worker.UvicornWorker app.main:app. Note the uvicorn_worker package (hyphen-to-underscore) — the uvicorn.workers stdlib module is deprecated in favor of the standalone uvicorn-worker package.
Configure pydantic-settings
Create app/config.py:
from functools import lru_cache
from typing import Annotated
from pydantic import field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
class Settings(BaseSettings):
clerk_secret_key: str
clerk_jwt_key: str | None = None
clerk_authorized_parties: Annotated[list[str], NoDecode] = []
clerk_webhook_signing_secret: str | None = None
@field_validator("clerk_authorized_parties", mode="before")
@classmethod
def _split_csv(cls, v: str | list[str]) -> list[str]:
if isinstance(v, str):
return [p.strip() for p in v.split(",") if p.strip()]
return v
model_config = SettingsConfigDict(env_file=".env", case_sensitive=False)
@lru_cache
def get_settings() -> Settings:
return Settings()Two details worth understanding here.
First, the NoDecode + field_validator pair prevents pydantic-settings from treating a CSV list as JSON (its default for list[str]), letting you use standard CLERK_AUTHORIZED_PARTIES='http://localhost:3000' formatting.
Second, @lru_cache ensures .env is read once, and allows clean testing via app.dependency_overrides.
Create the Clerk authentication dependency
This is the subsection to read twice. Everything else builds on it.
Create app/auth.py:
from typing import Annotated
from clerk_backend_api import AuthenticateRequestOptions, authenticate_request
from clerk_backend_api.security.types import RequestState
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.config import Settings, get_settings
http_bearer = HTTPBearer(auto_error=False)
def require_auth(
request: Request,
settings: Annotated[Settings, Depends(get_settings)],
_creds: Annotated[HTTPAuthorizationCredentials | None, Depends(http_bearer)] = None,
) -> RequestState:
state = authenticate_request(
request,
AuthenticateRequestOptions(
secret_key=settings.clerk_secret_key,
jwt_key=settings.clerk_jwt_key,
authorized_parties=settings.clerk_authorized_parties,
accepts_token=["session_token"],
),
)
if not state.is_signed_in:
raise HTTPException(
status_code=401,
detail=state.reason.name if state.reason else "unauthorized",
headers={"WWW-Authenticate": "Bearer"},
)
return stateA few decisions baked into this dependency are worth explaining.
HTTPBearer(auto_error=False), not OAuth2PasswordBearer. You aren't running an OAuth server; this provides the Swagger "Authorize" button and lets Clerk's SDK emit specific rejection reasons instead of generic 403s.
Pass Request directly into authenticate_request(). FastAPI's Request is a Starlette object with a headers mapping. Clerk's Requestish protocol is structurally satisfied — you don't wrap it, don't convert, don't .dict() it.
Module-level authenticate_request. Explicit, avoids context managers, and matches established patterns. (The instance method sdk.authenticate_request() works too).
Networkless with jwt_key. Using the PEM public key verifies signatures locally, which is faster and more resilient than the networked JWKS fallback.
Explicit authorized_parties. The Manual JWT verification guide says: "Neglecting to validate azp can expose your application to CSRF attacks." The list is your allow-list of frontend origins. It's a single line of defense, so don't skip it.
Explicit accepts_token=["session_token"]. The Python SDK accepts any token type by default. Pass this explicitly to restrict endpoints to user sessions.
Return RequestState, not state.payload. The full state includes is_signed_in (and its alias is_authenticated), status, reason, payload, token, and to_auth(). Downstream dependencies (require_permission, current_user) want all of it.
Protecting different types of endpoints
Three patterns you'll need: public, authenticated, and permission-gated.
Public endpoints need no dependency at all. Create app/routers/public.py:
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
def health():
return {"status": "ok"}
@router.get("/api/public/posts")
def public_posts():
return {"posts": [{"id": 1, "title": "Hello, world"}]}Authenticated endpoints inject require_auth. Create app/routers/protected.py:
from typing import Annotated
from clerk_backend_api.security.types import RequestState
from fastapi import APIRouter, Depends
from app.auth import require_auth
router = APIRouter(prefix="/api", tags=["protected"])
@router.get("/me")
def me(state: Annotated[RequestState, Depends(require_auth)]):
return {
"user_id": state.payload["sub"],
"session_id": state.payload.get("sid"),
}Admin-only endpoints stack a permission check on top. We'll build the require_permission factory in the next subsection. For now, the shape:
@router.get("/admin/users")
def list_admin_users(
_: Annotated[None, Depends(require_permission("org:admin:manage"))],
):
return {"users": []}A note on roles and permissions: The v2 session token carries the user's system role in the o.rol claim, enriched as payload["org_role"]. Custom permissions are compactly serialized across three claims (fea, o.per, o.fpm) and decoded into payload["org_permissions"]. System permissions are not serialized. See the RBAC section for details. Sources: session tokens guide, roles and permissions guide.
Accessing user context inside endpoints
Reading the user ID is a single claim lookup:
user_id = state.payload["sub"]That's enough for 90% of endpoints. The session token already has the user ID, organization ID, and custom permissions. You do not need to make a Backend API call to learn who the user is.
When you need profile data (name, email, metadata), call sdk.users.get(user_id=user_id). Construct the SDK client once at module scope so you reuse its connection pool:
from clerk_backend_api import Clerk
from functools import lru_cache
from app.config import get_settings
@lru_cache
def get_clerk() -> Clerk:
return Clerk(bearer_auth=get_settings().clerk_secret_key)Then in an endpoint:
from typing import Annotated
from clerk_backend_api import Clerk
from clerk_backend_api.security.types import RequestState
from fastapi import APIRouter, Depends
from app.auth import require_auth
router = APIRouter(prefix="/api")
@router.get("/me/profile")
def profile(
state: Annotated[RequestState, Depends(require_auth)],
clerk: Annotated[Clerk, Depends(get_clerk)],
):
user = clerk.users.get(user_id=state.payload["sub"])
email = next(
(address.email_address for address in user.email_addresses or []
if address.id == user.primary_email_address_id),
None,
)
return {
"id": user.id,
"email": email,
"public_metadata": user.public_metadata,
}Because FastAPI caches dependency results per request (use_cache=True default), adding state to multiple sub-dependencies doesn't cost extra calls.
Bridging auth to your own database. The state.payload["sub"] value is the Clerk user ID (user_xxxxxxxxxxxx). Use it as a foreign key into your own tables. A minimal SQLAlchemy 2.0 async lookup:
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import User
async def load_user(session: AsyncSession, clerk_id: str) -> User | None:
result = await session.execute(
select(User).where(User.clerk_id == clerk_id)
)
return result.scalar_one_or_none()The column name is clerk_id, matching Clerk's syncing guide. Not clerk_user_id, and not external_id — external_id is a distinct Clerk concept for importing third-party user IDs onto the Clerk user object. The User model itself is defined next to the webhook sync handler in Part 3. SQLModel users can write the same thing with identical Mapped syntax.
Role-based and permission-based access control
The two RBAC patterns worth knowing: custom permissions (in JWT, free) and system-role lookups (Backend API, slower).
Custom permissions use the org:<feature>:<permission> format. v2 session tokens encode permissions compactly across fea, o.per, and o.fpm claims. The Python SDK decodes this into a list at payload["org_permissions"]. Membership testing is simple:
from typing import Annotated
from clerk_backend_api.security.types import RequestState
from fastapi import Depends, HTTPException
from app.auth import require_auth
def require_permission(permission: str):
def _check(state: Annotated[RequestState, Depends(require_auth)]) -> RequestState:
org_permissions = state.payload.get("org_permissions") or []
if permission not in org_permissions:
raise HTTPException(status_code=403, detail=f"missing permission: {permission}")
return state
return _checkUse it in a route:
@router.post("/invoices")
def create_invoice(
_: Annotated[RequestState, Depends(require_permission("org:invoices:create"))],
):
return {"ok": True}System roles live in the o.rol claim for the user's active organization, without the org: prefix (e.g., "admin", "member"). The Python SDK copies that value to payload["org_role"] during authenticate_request() — see _process_payload in authenticaterequest.py. So the role check is local, with no network call:
def require_system_role(role: str):
"""Check role for the user's ACTIVE organization (the one in `o.rol`).
Pass the role without the `org:` prefix (e.g., `"admin"`, `"member"`) to
match what Clerk stores in the claim.
"""
def _check(
state: Annotated[RequestState, Depends(require_auth)],
) -> RequestState:
if not state.payload.get("org_id"):
raise HTTPException(status_code=403, detail="no organization context")
if state.payload.get("org_role") != role:
raise HTTPException(status_code=403, detail=f"missing role: {role}")
return state
return _checkUse it in a route:
@router.delete("/api/org/members/{member_id}")
def remove_member(
member_id: str,
_: Annotated[RequestState, Depends(require_system_role("admin"))],
):
...o.rol only carries the role for the active organization (o.id). To check roles in other organizations or enumerate memberships, fall back to the Backend API:
memberships = clerk.users.get_organization_memberships(user_id=state.payload["sub"])Prefer the local payload["org_role"] check whenever you're authorizing against the active org — it's faster and doesn't depend on the Backend API being reachable.
Source: roles and permissions, session token reference.
Handling authentication errors gracefully
When is_signed_in is False, state.reason is an enum member whose .name is a machine-readable code. The security/types.py source has the full enum:
SESSION_TOKEN_MISSING— no token on the requestTOKEN_EXPIRED— expired JWTTOKEN_INVALID_SIGNATURE— bad signatureTOKEN_INVALID_AUTHORIZED_PARTIES—azpnot in your allow-listTOKEN_INVALID— malformed or otherwise unverifiable tokenTOKEN_TYPE_NOT_SUPPORTED— got an M2M token on a session-only endpoint
Map them consistently. A custom exception handler for a uniform JSON shape:
from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse
# in app/main.py, after `app = FastAPI(...)`
@app.exception_handler(HTTPException)
def http_exception_handler(request: Request, exc: HTTPException):
errors = {401: "unauthorized", 403: "forbidden"}
return JSONResponse(
status_code=exc.status_code,
content={"error": errors.get(exc.status_code, "error"),
"reason": exc.detail},
headers=exc.headers or {},
)Register it in app/main.py (or pass app into a helper) so you don't import app.main from a module that main.py itself imports. The handler is app-wide — any HTTPException flows through it, which is why the non-auth statuses fall back to a generic label instead of "forbidden".
Always include WWW-Authenticate: Bearer on 401s for RFC 6750 compliance. Never log the raw Authorization header or token; log the rejection reason, path, and user ID to avoid CWE-532 vulnerabilities.
Testing your FastAPI endpoints
The sync TestClient still works and is the simplest default:
import pytest
from clerk_backend_api.security.types import AuthStatus, RequestState
from fastapi.testclient import TestClient
from app.auth import require_auth
from app.main import app
def _fake_auth() -> RequestState:
return RequestState(
status=AuthStatus.SIGNED_IN,
payload={"sub": "user_fake123", "sid": "sess_fake"},
)
@pytest.fixture(autouse=True)
def _override_auth():
app.dependency_overrides[require_auth] = _fake_auth
yield
app.dependency_overrides = {}
def test_me_endpoint():
client = TestClient(app)
response = client.get("/api/me")
assert response.status_code == 200
assert response.json() == {"user_id": "user_fake123", "session_id": "sess_fake"}
def test_unauthenticated_returns_401():
app.dependency_overrides = {}
client = TestClient(app)
response = client.get("/api/me")
assert response.status_code == 401Overriding require_auth at the dependency level is cleaner than patching authenticate_request globally, leaving the rest of the request chain intact. The override returns a real RequestState (setting status=AuthStatus.SIGNED_IN is enough — is_signed_in is a computed property), so endpoints that read state.status, state.token, or state.reason, not just state.payload, behave as they do in production.
For async tests that need to await something (async DB sessions, an external API), switch to httpx.AsyncClient with ASGITransport:
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_me_async():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
response = await ac.get("/api/me")
assert response.status_code == 200Add pytest-asyncio (1.3.0+ on PyPI, 2025-11-10) and put this in pyproject.toml:
[tool.pytest_asyncio]
asyncio_mode = "auto"With asyncio_mode = "auto", every async def test_* function is auto-marked — you don't have to sprinkle @pytest.mark.asyncio on each one. @pytest.mark.anyio is FastAPI's own preferred spelling (it can run the same test under both asyncio and trio) and is equivalent for an asyncio-only project.
One gotcha: AsyncClient + ASGITransport does not trigger FastAPI's lifespan events (startup and shutdown). If your tests depend on startup hooks (database connection pools, etc.), wrap with asgi-lifespan's LifespanManager:
from asgi_lifespan import LifespanManager
@pytest.mark.asyncio
async def test_with_lifespan():
async with LifespanManager(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
response = await ac.get("/api/me")Avoid over-mocking to catch token parsing bugs. For integration tests, use a real dev session token from getToken(). Sources: FastAPI testing dependencies, FastAPI async tests, pytest-asyncio concepts.
Adding Clerk authentication to Flask
Parallel section to FastAPI. Flask is simpler, less opinionated, and the integration is a decorator instead of a dependency. If you're coming from FastAPI, the patterns map one-to-one; only the syntax changes.
Project setup from scratch
uv init python-flask-auth
cd python-flask-auth
uv add flask clerk-backend-api python-dotenvMinimal app/__init__.py:
import os
from flask import Flask
from dotenv import load_dotenv
load_dotenv()
def env_csv(key: str, default: list[str] | None = None) -> list[str]:
raw = os.environ.get(key)
if not raw:
return default or []
return [p.strip() for p in raw.split(",") if p.strip()]
def create_app() -> Flask:
app = Flask(__name__)
app.config.update(
CLERK_SECRET_KEY=os.environ["CLERK_SECRET_KEY"],
CLERK_JWT_KEY=os.environ.get("CLERK_JWT_KEY"),
CLERK_AUTHORIZED_PARTIES=env_csv("CLERK_AUTHORIZED_PARTIES"),
CLERK_WEBHOOK_SIGNING_SECRET=os.environ.get("CLERK_WEBHOOK_SIGNING_SECRET"),
)
@app.get("/health")
def health():
return {"status": "ok"}
from app.routes.protected import bp as protected_bp
app.register_blueprint(protected_bp)
return appRun the dev server:
uv run flask --app app run --debug --port 5000For production, add Gunicorn (uv add gunicorn), then: gunicorn "app:create_app()" -w 4 -b 0.0.0.0:8000. The -w $((2*$(nproc)+1)) formula from the Gunicorn docs is a reasonable default for I/O-bound Python apps. Source: Flask testing docs.
The Flask auth decorator
Flask's canonical pattern for cross-cutting concerns is a decorator. Build it once, use it everywhere.
Create app/auth.py:
from functools import wraps
from clerk_backend_api import AuthenticateRequestOptions, authenticate_request
from flask import abort, current_app, g, request
def clerk_required(view):
@wraps(view)
def wrapper(*args, **kwargs):
state = authenticate_request(
request,
AuthenticateRequestOptions(
secret_key=current_app.config["CLERK_SECRET_KEY"],
jwt_key=current_app.config.get("CLERK_JWT_KEY"),
authorized_parties=current_app.config["CLERK_AUTHORIZED_PARTIES"],
accepts_token=["session_token"],
),
)
if not state.is_signed_in:
abort(401, description=state.reason.name if state.reason else "unauthorized")
g.auth_state = state
g.user_id = state.payload["sub"]
return view(*args, **kwargs)
return wrapperA few Flask-specific decisions worth calling out.
Flask's request satisfies Requestish. Pass it directly without wrapping; EnvironHeaders is a valid mapping.
g is Flask's per-request namespace. Thread-safe and scoped to the request, it's the ideal place for g.auth_state and g.user_id.
functools.wraps. Always wrap, or Flask routing breaks due to name collisions.
env_csv in create_app. Flask has no built-in type coercion, so we convert the CLERK_AUTHORIZED_PARTIES string to a list once at startup. The naive os.environ.get("CLERK_AUTHORIZED_PARTIES", "").split(",") returns [""] on an unset var — which doesn't actually fail the Clerk azp check (the empty list would), but it leaks an empty string into the comparison and makes debugging harder. Trim and filter.
Alternative: before_request global middleware
When almost every route is protected, a decorator on every view feels noisy. Global middleware via before_request is cleaner.
from flask import g, request, abort
from clerk_backend_api import AuthenticateRequestOptions, authenticate_request
PUBLIC_ENDPOINTS = {"static", "health", "webhooks.clerk_webhook", "protected.public_posts"}
def register_auth_middleware(app):
@app.before_request
def _authenticate():
if request.endpoint in PUBLIC_ENDPOINTS:
return None
state = authenticate_request(
request,
AuthenticateRequestOptions(
secret_key=app.config["CLERK_SECRET_KEY"],
jwt_key=app.config.get("CLERK_JWT_KEY"),
authorized_parties=app.config["CLERK_AUTHORIZED_PARTIES"],
accepts_token=["session_token"],
),
)
if not state.is_signed_in:
abort(401, description=state.reason.name if state.reason else "unauthorized")
g.auth_state = state
g.user_id = state.payload["sub"]Call register_auth_middleware(app) inside create_app(). The allow-list uses request.endpoint (the Flask route name) rather than request.path (/health) because endpoint names are more stable when you add URL prefixes. Watch the naming: routes registered on a blueprint get the blueprint's name as a prefix — the webhook view from Part 3 is webhooks.clerk_webhook and the public posts view below is protected.public_posts, while app-level routes like health are unprefixed. An endpoint missing from the set gets auth by default, so a typo here fails closed (a 401), not open.
Per-route decorator vs. global middleware is a tradeoff. Prefer the decorator if most routes are public. Prefer global middleware if most routes are protected and you want auth-by-default. Don't mix both in the same app — it becomes hard to reason about which path ran which check.
Protecting different types of endpoints
Create app/routes/protected.py:
from flask import Blueprint, g, jsonify
from app.auth import clerk_required, require_permission
bp = Blueprint("protected", __name__, url_prefix="/api")
@bp.get("/public/posts")
def public_posts():
return jsonify({"posts": [{"id": 1, "title": "Hello, world"}]})
@bp.get("/me")
@clerk_required
def me():
return jsonify({"user_id": g.user_id})
@bp.get("/admin/users")
@clerk_required
@require_permission("org:admin:manage")
def list_admin_users():
return jsonify({"users": []})Decorator order matters. Route registration (@bp.get) must be outermost, followed by @clerk_required, then @require_permission. Flipping the auth and permission decorators causes crashes against uninitialized g.auth_state.
Accessing user context inside view functions
g.user_id is already set. For profile data, call sdk.users.get() via a cached helper:
from functools import cache
from clerk_backend_api import Clerk
from flask import current_app, g
@cache
def _sdk() -> Clerk:
return Clerk(bearer_auth=current_app.config["CLERK_SECRET_KEY"])
def current_user():
if "_current_user" not in g:
g._current_user = _sdk().users.get(user_id=g.user_id)
return g._current_userThis provides a current_user() API similar to Flask-Login, but using Clerk.
Bridging to your database. Once the decorator has verified the token, g.user_id is the Clerk user ID you join against. A SQLAlchemy 2.0 sync lookup (works with Flask-SQLAlchemy 3.x or plain SQLAlchemy):
from sqlalchemy import select
from app.extensions import db
from app.models import User
@bp.get("/me/subscription")
@clerk_required
def my_subscription():
user = db.session.scalar(select(User).where(User.clerk_id == g.user_id))
if not user:
return {"subscription": "free"}
return {"subscription": user.subscription_tier}If you're on Flask-SQLAlchemy 2.x, User.query.filter_by(clerk_id=g.user_id).first() works, but the 2.0 select() style matches what upstream SQLAlchemy will support going forward. Column name is clerk_id, per Clerk's syncing guide. The full User model is in Part 3.
RBAC and permission checks
Same pattern as FastAPI — read the decoded payload["org_permissions"] list that authenticate_request() populates from the v2 fea / o.per / o.fpm claims:
from functools import wraps
from flask import g, abort
def require_permission(permission: str):
def decorator(view):
@wraps(view)
def wrapper(*args, **kwargs):
org_permissions = g.auth_state.payload.get("org_permissions") or []
if permission not in org_permissions:
abort(403, description=f"missing permission: {permission}")
return view(*args, **kwargs)
return wrapper
return decoratorFor system-role checks, read payload["org_role"] — same local check as FastAPI. The Python SDK populates it from the v2 o.rol claim during authenticate_request(), so no Backend API call is needed for the active organization:
def require_system_role(role: str):
"""Pass `role` without the `org:` prefix (e.g., `"admin"`)."""
def decorator(view):
@wraps(view)
def wrapper(*args, **kwargs):
payload = g.auth_state.payload
if not payload.get("org_id"):
abort(403, description="no organization context")
if payload.get("org_role") != role:
abort(403, description=f"missing role: {role}")
return view(*args, **kwargs)
return wrapper
return decoratorIf you need to enumerate every organization the user belongs to (not just the active one), or check a role in a non-active organization, you still fall back to _sdk().users.get_organization_memberships(user_id=g.user_id) — that's the only case where a Backend API call is required.
Error handling for APIs
Flask's default 401 renders HTML. For a JSON API, register a handler:
from flask import jsonify
from werkzeug.exceptions import HTTPException
def register_error_handlers(app):
@app.errorhandler(HTTPException)
def _json_errors(exc: HTTPException):
return jsonify({
"error": "unauthorized" if exc.code == 401 else
"forbidden" if exc.code == 403 else
exc.name.lower().replace(" ", "_"),
"reason": exc.description,
}), exc.codeRegister this inside create_app(). Same error shape as the FastAPI example, so clients can code against a consistent contract regardless of which Python framework your team landed on.
Testing Flask endpoints
Flask's test_client() is the idiomatic path. The key design decision: use two fixtures, one that exercises the real clerk_required decorator (for 401 tests) and one that injects a fake authenticated user (for happy-path tests). The common tempting shortcut — monkeypatch.setattr("app.auth.clerk_required", ...) — does not work, because routes captured the original decorator reference at import time and pytest's monkeypatch can't retroactively rebind those references.
The cleanest pattern makes clerk_required test-aware via a config flag, and injects the fake user with @app.before_request. Update app/auth.py to honor the flag:
from flask import current_app, g, request
from functools import wraps
def clerk_required(view):
@wraps(view)
def wrapper(*args, **kwargs):
if current_app.config.get("TESTING_AUTH") and hasattr(g, "user_id"):
return view(*args, **kwargs) # trust before_request-injected g
# ...real authenticate_request() path from "The Flask auth decorator" above...
return wrapperThen in tests/conftest.py:
import pytest
from clerk_backend_api.security.types import AuthStatus, RequestState
from flask import g
from app import create_app
@pytest.fixture
def app():
"""Real app with the real clerk_required decorator wired up."""
app = create_app()
app.config.update(TESTING=True)
return app
@pytest.fixture
def client(app):
"""Anonymous client — no auth injected. Real decorator runs and returns 401."""
with app.test_client() as c:
yield c
@pytest.fixture
def authenticated_client(app):
"""Client with a fake authenticated user injected via before_request."""
app.config["TESTING_AUTH"] = True
@app.before_request
def _inject_fake_auth():
g.auth_state = RequestState(
status=AuthStatus.SIGNED_IN,
payload={"sub": "user_fake"},
)
g.user_id = "user_fake"
with app.test_client() as c:
yield cNow each test picks the fixture that matches the path it wants to exercise:
def test_me_returns_user_id(authenticated_client):
response = authenticated_client.get("/api/me")
assert response.status_code == 200
assert response.get_json() == {"user_id": "user_fake"}
def test_unauthenticated_returns_401(client):
response = client.get("/api/me")
assert response.status_code == 401
assert response.get_json()["error"] == "unauthorized"client runs the real decorator (no bypass), so the 401 assertion actually exercises authenticate_request(). authenticated_client sets TESTING_AUTH=True and populates g — the decorator short-circuits cleanly, and because the app fixture is function-scoped, the hook never leaks across tests.
For integration tests against a real Clerk dev instance, use a session token from a browser session: sign in via a local frontend, call window.Clerk.session.getToken() in the browser console, then use that token in client.get('/api/me', headers={'Authorization': f'Bearer {token}'}).
Brief: using Clerk with Django / DRF
Clerk does not ship a first-party Django package in 2026. Avoid outdated community packages like clerk-django or django-clerk.
The idiomatic Django integration uses Django REST Framework's BaseAuthentication. It returns a (user, auth) tuple. A lightweight ClerkUser stub with is_authenticated = True skips the Django user model entirely.
from dataclasses import dataclass
from clerk_backend_api import AuthenticateRequestOptions, authenticate_request
from clerk_backend_api.security.types import AuthErrorReason
from django.conf import settings
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
@dataclass
class ClerkUser:
"""Minimal `request.user` for Clerk-authenticated requests.
Clerk owns the user directory; this object exposes just enough for
DRF's `IsAuthenticated` permission. If the app also maintains a
local User row (via Clerk webhooks), look it up by `id` — the Clerk
`sub` — in the view or a thin helper.
"""
id: str
payload: dict
is_authenticated: bool = True
is_anonymous: bool = False
is_active: bool = True
def __str__(self) -> str:
return self.id
class ClerkAuthentication(BaseAuthentication):
def authenticate(self, request):
state = authenticate_request(
request,
AuthenticateRequestOptions(
secret_key=settings.CLERK_SECRET_KEY,
jwt_key=settings.CLERK_JWT_KEY,
authorized_parties=settings.CLERK_AUTHORIZED_PARTIES,
accepts_token=["session_token"],
),
)
if not state.is_signed_in:
if state.reason is AuthErrorReason.SESSION_TOKEN_MISSING:
return None # no token supplied; DRF treats this as "not attempted"
raise AuthenticationFailed(state.reason.name if state.reason else "unauthorized")
user = ClerkUser(id=state.payload["sub"], payload=state.payload)
return (user, state)
def authenticate_header(self, request):
return "Bearer"If you need to join request data against local rows (profiles, subscriptions, app-specific fields), sync users via Clerk webhooks and look up the local record by the Clerk ID exposed as request.user.id. See Sync Clerk data to your application with webhooks. Clerk's syncing guide explicitly recommends skipping a local user table when you don't need one: "If you can access the necessary data directly from the Clerk session token, you can achieve strong consistency while avoiding the overhead of maintaining a separate user table."
A distinction worth internalizing: returning None tells DRF the request wasn't attempted with this scheme (so other authenticators — or anonymous access — can proceed), while raising AuthenticationFailed rejects a request that did present a token but an invalid one. Collapsing both into None would let a request with an expired or forged token fall through as merely anonymous.
Register it in settings.py:
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"yourapp.auth.ClerkAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
}The DEFAULT_PERMISSION_CLASSES line is not optional. DRF authentication only identifies the caller — it never denies a request. Without it, DRF's default permission is AllowAny and every endpoint stays public no matter how good the authentication class is. Opt public views back out with permission_classes = [AllowAny].
Permission checks on a ViewSet:
from rest_framework.permissions import BasePermission
def has_clerk_permission(permission: str):
class _HasClerkPermission(BasePermission):
def has_permission(self, request, view):
state = request.auth # the RequestState returned by authenticate()
if state is None:
return False
org_permissions = state.payload.get("org_permissions") or []
return permission in org_permissions
return _HasClerkPermissionUse it with permission_classes = [has_clerk_permission("org:invoices:create")]. The factory shape matters: DRF instantiates each entry in permission_classes with no arguments, so a permission class with a required constructor argument can't be registered directly — the closure carries the permission string instead.
For plain Django (not DRF), subclass MiddlewareMixin and populate request.user from the authenticated state inside process_request. Docs: DRF authentication.
Conclusion
You now have a complete, production-ready authentication layer in your Python backend. Whether you chose FastAPI's elegant dependencies, Flask's canonical decorators, or Django REST Framework's class-based authentication, your endpoints are secure. You've implemented local JWT verification for performance and handled Role-Based Access Control directly from the token claims.
In the final part of this series, we will bridge this secure foundation to your application's data.
FAQ
In this series
- How to Add Authentication to a Python Backend
- How to Add Authentication to a Python Backend - Part 2 (you are here)
- How to Add Authentication to a Python Backend - Part 3