
How to Add Authentication to a Python Backend
How do I add authentication to a Python backend?
A Python backend authenticates by verifying a signed token the frontend attaches to every request — it does not render sign-in forms, run OAuth handshakes, or store passwords. The recommended 2026 stack is Clerk's official clerk-backend-api for token verification on the Python side, paired with any Clerk frontend SDK (React, Next.js, Expo, Vanilla JS, iOS, Android) for the sign-in UI. The walkthrough below covers FastAPI and Flask in depth, Django briefly, and the React call-site pattern for completeness.
authenticate_request() accepts any request object that exposes a headers mapping (source) — that covers FastAPI Request, Flask request, Django HttpRequest, Starlette Request, and Sanic Request directly. Raw ASGI scopes or other shapes without a headers attribute need a thin adapter.
Quick reference
Jump to your framework:
A minimal FastAPI example
Here's the smallest useful protected endpoint. Full code with project scaffolding and error handling appears later.
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Request
from clerk_backend_api import authenticate_request, AuthenticateRequestOptions
import os
app = FastAPI()
def require_user(request: Request) -> str:
state = authenticate_request(
request,
AuthenticateRequestOptions(
secret_key=os.environ["CLERK_SECRET_KEY"],
jwt_key=os.environ.get("CLERK_JWT_KEY"),
authorized_parties=["http://localhost:3000"],
accepts_token=["session_token"],
),
)
if not state.is_signed_in:
raise HTTPException(status_code=401, detail=state.reason)
return state.payload["sub"]
@app.get("/api/me")
def me(user_id: Annotated[str, Depends(require_user)]):
return {"user_id": user_id}That's everything. The frontend calls fetch('/api/me', { headers: { Authorization: 'Bearer <token>' } }) and Clerk's SDK verifies the signature locally against CLERK_JWT_KEY. No network call per request, no session storage, no password hashing. The only thing the backend has to know how to do is verify a signature.
Who this guide is for
This article is for three readers:
- Python developers building a FastAPI, Flask, or Django backend who need to protect API endpoints and don't want to write JWT verification from scratch.
- React or Next.js developers who already use Clerk on the frontend and need the backend half. You're comfortable with Clerk's React components but haven't touched the Python SDK yet.
- Developers new to authentication who want a production-ready setup without rolling their own. You've heard the words JWT, OAuth, and SSO, but you don't want to build any of them.
Assumptions:
- Python 3.10 or higher. The current
clerk-backend-api(v5.0.6) requires>=3.10per itspyproject.toml. If you're on 3.8 or 3.9, upgrade Python (recommended) or pinclerk-backend-api<3(discouraged, predates the current API). - Familiarity with HTTP and at least one of FastAPI or Flask.
- A package manager:
uv,pip, orpoetry. Examples useuvfirst,pipsecond. - A frontend that can acquire a Clerk session token: React, Next.js, Expo, mobile, or a Clerk-aware API client.
How to use this guide. Sections 3 through 5 (mental model, options, setup) apply to any Python backend. Read them once. Then skip to your framework: Section 6 for FastAPI, Section 7 for Flask, Section 8 for Django/DRF. The React integration, advanced SDK features, production deployment notes, and comparison table are framework-agnostic and come after. The FAQ is a scannable index for when you hit a specific error or concept.
How Python backend authentication actually works
If you take one thing from this article, take this: the frontend acquires the token, the backend verifies it. That's the whole model. Everything else is plumbing.
Frontend vs. backend responsibilities
The frontend is where the human is. It collects passwords (or a passkey prompt, or an OAuth redirect, or an MFA code), hands those to the auth provider's Frontend API, and receives a signed session token back. It then attaches that token to every API call, typically as Authorization: Bearer <token> or a __session cookie.
The backend never sees the password. The backend never runs the OAuth dance. The backend's only job is to verify that the token is genuine, fresh, and from a party it trusts, then read the claims (who is this user? what org are they in? what permissions do they have?) and authorize the request.
A full request lifecycle with Clerk looks like this:
- Browser loads your app.
- Clerk's frontend SDK talks to the Clerk Frontend API and mints a session.
- User makes an action that calls your Python backend.
- Frontend fetches the short-lived session token via
getToken()and attaches it to the request. - Python backend receives the request, passes it to
authenticate_request(). authenticate_request()verifies the RS256 signature using the cached public key, checks expiry, checks theazpclaim against your allow-list, returns the claims.- Your handler authorizes the action and returns a response.
There's no handshake to Clerk on that critical path. With jwt_key (networkless mode), verification is a local RS256 signature check — no network round-trip to Clerk on verification. The networked fallback makes a one-time JWKS fetch per process per kid, cached in memory, and verifies locally from then on. See the authenticateRequest reference for the exact behavior.
Five misconceptions worth clearing up first
"Python can handle sign-up and sign-in directly." Not in modern auth, no. Sign-up and sign-in involve OAuth redirects, passkey WebAuthn flows, MFA challenges, session refresh with rolling tokens — all of which live in the browser or mobile client. A Python framework can render a form, but the moment you add Google login, passkeys, or magic links, you've moved that flow into the browser anyway. A real community example: clerk/clerk-sdk-python#59 asks for a CLI sign-in helper, which isn't how Clerk's SDK works.
"Clerk's Python SDK has the same UI components as the React SDK." It does not. clerk-backend-api is backend-only. It verifies tokens, reads user data, manages sessions, and handles webhooks. Components like <SignIn />, <UserButton />, and <OrganizationSwitcher /> ship only in the frontend SDKs (@clerk/clerk-react, @clerk/nextjs, @clerk/expo, etc.). The pairing pattern is simple: use Clerk's frontend SDK on the client, clerk-backend-api on the Python server.
"I need to store passwords or session tokens in my database." No. Clerk stores users, passwords, active sessions, MFA factors, OAuth linkages, and impersonation audit trails. Your Python database only stores your application data, keyed by the Clerk user ID (user_xxx...). Clerk's syncing guide documents the canonical pattern: when a user.created webhook arrives, insert a row with clerk_id=data["id"] and nothing else password-adjacent.
"JWT verification requires calling the auth provider on every request." No. RS256 JWTs are asymmetric. The issuer (Clerk) signs with a private key. You verify with the public key. If you pass jwt_key= into AuthenticateRequestOptions with the PEM-formatted public key, verification is a local math operation — zero network calls. The networked fallback fetches JWKS from https://api.clerk.com/v1/jwks once per process per kid and caches it in memory. Either way, you are not round-tripping to Clerk on every request.
"I have to manage CORS, cookies, and tokens manually." The SDK reads the token automatically. It checks Authorization: Bearer <token> first, then the __session cookie as a fallback. FastAPI's Request and Flask's request both satisfy the Requestish structural protocol the SDK expects — no wrapping, no adapter. You do have to configure CORS on your Python side (covered in Section 11), but token extraction is not something you write.
Token formats a Python backend sees
Clerk emits several token types. Most Python backends only handle the first one, but it's worth knowing the rest exist.
A note on defaults that trips up teams migrating from the Node SDK. In the current Python SDK (5.0.6), the accepts_token field on AuthenticateRequestOptions defaults to ['any'] — every token type above is accepted by default. Clerk's canonical authenticateRequest reference documents the JS/Node SDK default as 'session_token', and the Node SDK enforces that default. For parity with the documented default and defense in depth, pass accepts_token=['session_token'] explicitly on session-only endpoints; restrict M2M-only endpoints with accepts_token=['m2m_token'] or combine types with accepts_token=['session_token', 'api_key']. The full token type definition lives in the SDK source.
One note worth keeping in your pocket: the default session token format is v2. Clerk deprecated v1 on 2025-04-14; in the raw JWT, org claims that used to be flat (org_id, org_role) are now nested under an o object (o.id, o.rol, o.per). The Python SDK smooths this over by re-surfacing the flat names on payload after authenticate_request() — payload["org_id"], payload["org_role"], payload["org_slug"], plus the decoded payload["org_permissions"] — so application code can read them directly without touching the nested payload["o"] dict. Copy-pasted code from mid-2024 tutorials that reached into the (now-removed) top-level org_id / org_role JWT claims will still work via payload[...] because of that SDK-level enrichment, but anything that reads straight from the JSON-decoded token still has to go through o.*.
Your options for Python backend authentication
There are three realistic paths. Most teams pick option 3 once they count the real cost of the others.
Option 1: Roll your own with PyJWT + pwdlib
You can, in theory, build authentication yourself. You'd hash passwords (Argon2id is the OWASP 2024 recommendation), mint and verify JWTs, rotate signing keys, send verification emails, handle password resets, implement MFA, integrate passkeys via WebAuthn, wire up OAuth clients for every social provider, rate-limit login endpoints, detect credential stuffing, and commit to a SOC 2 audit cycle.
WorkOS's 2026 Python authentication guide estimates 2–6 weeks for an MVP, 2–3 months for a production-ready system, and $50,000–$200,000 per year for SOC 2 compliance alone. That's before passkeys or organizations.
A critical note on libraries if you're reading older tutorials: the classic FastAPI stack was python-jose + passlib. Both are effectively unmaintained. python-jose carries CVE-2024-33663, an algorithm confusion vulnerability with a CVSS score of 6.5. passlib's last release was October 2020 and it breaks with bcrypt ≥5.0. FastAPI itself officially migrated to PyJWT and pwdlib with Argon2 support in May 2024. If you're going to roll your own, use those instead.
Where rolling your own fits: learning exercises, internal tools with no external users, or cases where auth is literally your product.
Option 2: Framework extensions
Flask-Login is session-cookie based. It doesn't fit a stateless bearer-token API where the frontend and backend are decoupled (a React SPA calling a Python API). The last release was 0.6.3 in October 2023.
FastAPI Users is in maintenance mode. The maintainers have publicly stated they're only accepting security and dependency updates; no new features. A successor project is discussed in the repo but isn't shipping.
django-allauth (currently 65.16.0) is the one framework extension that's still actively maintained and feature-complete. It includes MFA, WebAuthn, 100+ social providers, and email verification. It fits Django apps with server-rendered pages. It does not fit decoupled SPA + API architectures because it's built around Django's session middleware.
None of these give you passkeys plus MFA plus organizations plus webhooks plus prebuilt React UI in one package.
Option 3: Managed auth providers
This is the category Clerk, Auth0, Supabase Auth, Firebase Auth, and AWS Cognito all live in. What they share: hosted user database, prebuilt frontend flows, JWT-based backend verification, SOC 2 compliance, passkey support.
Where they differ matters a lot for Python specifically:
- First-party Python SDK maturity and release cadence
- Dedicated FastAPI / Flask / Django helpers
- Passkey and MFA availability on the free or low tiers
- Organizations / multi-tenant B2B support
- Networkless JWT verification without DIY JWKS management
- Free-tier unit (MRU vs MAU) and allowance
- Transparent pricing
Why Clerk is the focus of this guide: first-party clerk-backend-api with monthly releases (5.0.6 in March 2026), strong organizations / B2B support, prebuilt React / Next.js / Expo frontends that match the Python backend one-to-one, passkeys included in the Pro plan, and transparent MRU-based pricing. Python-specific helpers ship in the same SDK: networkless verification, webhook handling, M2M tokens, and organization management are all one import away.
Full comparison table appears in Section 12. Short version: for a new Python API paired with a modern frontend, Clerk is the path with the fewest decisions to make.
Setting up Clerk for a Python backend
Before you touch FastAPI or Flask specifics, get these three things in place: a Clerk application, your keys, and a sane environment config.
Prerequisites
Create a Clerk application and collect your keys
Create an application in the Clerk Dashboard. Enable at least one sign-in method (email + password is enough to start; add passkeys, social OAuth, or magic links later).
You'll collect four pieces of configuration:
- Publishable key (
pk_test_...orpk_live_...) — frontend. Safe to ship in browser bundles. Identifies your Clerk application. - Secret key (
sk_test_...orsk_live_...) — backend. Never ships to the client. Authorizes Backend API calls. - JWT public key (PEM) — backend. Used for networkless token verification. Find this at Dashboard → API keys → scroll to "Advanced" → "Show JWT public key", which reveals a PEM-formatted
-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----block. Confirmed against the Manual JWT verification guide and theauthenticateRequestreference. - Webhook signing secret (
whsec_...) — backend. Only needed when you add webhooks. We'll cover this in Section 10.
Development vs. production keys matter. Use pk_test_ and sk_test_ locally; switch to pk_live_ and sk_live_ for production deploys. Never share a secret key with the frontend and never commit it to source control.
Install the Clerk Python SDK
uv add clerk-backend-apiEquivalent with pip:
pip install clerk-backend-apiOr poetry:
poetry add clerk-backend-apiConfirm the install: python -c "import clerk_backend_api; print(clerk_backend_api.__version__)" should show 5.0.6 or later. The package is auto-generated from Clerk's OpenAPI spec via Speakeasy, and sync and async variants live on the same Clerk class — there's no separate AsyncClerk. See sdk.py if you're curious about the structure.
Source: clerk-backend-api on PyPI, GitHub repo.
Environment configuration
Create a .env at the project root:
CLERK_SECRET_KEY=sk_test_...
CLERK_JWT_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
CLERK_AUTHORIZED_PARTIES=http://localhost:3000,https://yourapp.com
CLERK_WEBHOOK_SIGNING_SECRET=whsec_...The CLERK_JWT_KEY value is the PEM block copied from the Dashboard, with real newlines replaced by \n. In FastAPI you'll let pydantic-settings parse it; in Flask you'll use python-dotenv. http://localhost:3000 and https://yourapp.com are placeholders for your real development and production frontend origins — replace both before deploying.
Add .env to your .gitignore:
.env
.env.*
!.env.exampleA common gotcha: CLERK_AUTHORIZED_PARTIES is a string when it lands in os.environ, but AuthenticateRequestOptions expects list[str]. Passing the raw string puts the entire comma-joined value in as a single list element, and every request fails with TOKEN_INVALID_AUTHORIZED_PARTIES. We handle this properly in the FastAPI (Section 6b) and Flask (Section 7b) configs.
Why CLERK_JWT_KEY is a PEM and not the publishable key: the publishable key identifies your Clerk application to the Frontend API. The JWT public key is the RSA public half of the keypair Clerk uses to sign session tokens. It's what you actually verify signatures with. They're different values, not interchangeable. The Clerk docs also match these names in their canonical environment variables guide. Note: the archived clerk/fastapi-example uses CLERK_API_SECRET_KEY instead of CLERK_SECRET_KEY. The docs and every other Clerk SDK use CLERK_SECRET_KEY. If you're copy-pasting from the archived example, rename the variable.
Recommended project structure
Two parallel layouts depending on framework.
FastAPI:
app/
main.py # FastAPI() + CORS + router registration
config.py # Settings(BaseSettings) + get_settings()
auth.py # require_auth dependency, require_permission factory
routers/
public.py
protected.py
webhooks.py # Clerk webhook endpoint
tests/
.env
pyproject.tomlFlask:
app/
__init__.py # create_app() factory + CORS + blueprint registration
config.py # Config class reading from os.environ
auth.py # @clerk_required decorator + @require_permission
routes/
public.py # Blueprint
protected.py # Blueprint
webhooks.py # Blueprint with raw-body handler
tests/
.env
pyproject.tomlThe exact code for each file appears in the framework sections below. The layout is not load-bearing; use what fits your team.
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: 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. pydantic-settings v2 decodes list[str] fields as JSON by default. A plain CSV env value that joins two origins with a comma raises JSONDecodeError at startup. NoDecode opts that one field out; the validator splits on commas, trims whitespace, drops empties. The alternative is JSON-in-env (CLERK_AUTHORIZED_PARTIES='["http://localhost:3000"]'), which is valid but awkward for Docker or Kubernetes. See pydantic-settings parsing environment variable values and issue #291 for the underlying behavior.
Second, @lru_cache. Reading .env once at startup is correct. The cache makes get_settings() idempotent and testable: in tests, override with app.dependency_overrides[get_settings] = lambda: Settings(clerk_secret_key="sk_test_fake", ...). Source: FastAPI settings docs.
Pydantic v1 note: the NoDecode + field_validator + SettingsConfigDict syntax above is pydantic-settings v2, which extracted BaseSettings out of pydantic core into a separate package at the v2.0 release on 2023-06-30. pip install pydantic-settings pulls v2.x today (current 2.13.3, April 2026). Legacy v1 codebases keep BaseSettings inside pydantic itself, do not have NoDecode, and use @validator("...", pre=True) with an inner class Config. Migration guide: pydantic migration. Active development on v1 ended 2024-06-30, so new projects should be on v2.
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 or "unauthorized",
headers={"WWW-Authenticate": "Bearer"},
)
return stateA few decisions baked into this dependency are worth explaining.
HTTPBearer(auto_error=False), not OAuth2PasswordBearer. Clerk is the issuer, you're not running an OAuth server, and the tutorial pattern of OAuth2PasswordBearer(tokenUrl="token") doesn't apply. HTTPBearer gives you the Swagger "Authorize" button and a clean security scheme in the OpenAPI spec without pretending you expose a password flow. auto_error=False lets Clerk's SDK emit the specific rejection reason (SESSION_TOKEN_MISSING, TOKEN_EXPIRED, etc.) instead of a generic 403. Source: FastAPI security first steps.
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, not clerk.authenticate_request(...) on an instance. Both work. Module-level is explicit about what's happening, avoids needing a with Clerk(...) context manager, and matches the pattern the now-archived clerk/fastapi-example used. If you prefer the instance method, construct a module-level sdk = Clerk(bearer_auth=settings.clerk_secret_key) and call sdk.authenticate_request(request, AuthenticateRequestOptions(...)). The instance method auto-pulls secret_key from bearer_auth if you don't pass it explicitly.
Networkless with jwt_key. The jwt_key=settings.clerk_jwt_key argument is the PEM public key. When present, the SDK verifies the RS256 signature locally. When absent, the SDK falls back to the networked path: fetch JWKS from https://api.clerk.com/v1/jwks, cache by kid, retry on mismatch for key rotation. Both work; networkless is faster and more resilient. See verifytoken.py for the exact behavior.
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's AuthenticateRequestOptions.accepts_token defaults to ['any'] — meaning without this argument, the dependency will also accept API keys, M2M tokens, and OAuth access tokens on the same endpoint. Clerk's authenticateRequest reference documents the default as 'session_token' (the Node SDK enforces that value). The Python SDK is permissive where the docs are restrictive, so pass accepts_token explicitly on every session-only endpoint. Machine-auth endpoints swap the list (shown in Section 10f).
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 that often trip readers up. The v2 session token carries the user's system role in the o.rol claim (without the org: prefix, e.g., "admin"), and the Python SDK enriches the payload with payload["org_role"] so you can read it directly — no Backend API call. Custom permissions are serialized compactly across three claims (fea, o.per, o.fpm) that the SDK decodes into the full org:<feature>:<permission> strings at payload["org_permissions"]. System permissions (like org:sys_memberships:manage) are not serialized at all — if you need those server-side, create a custom permission and assign it to the system role in the Dashboard. Full details and worked examples are in the RBAC section. 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"])
return {
"id": user.id,
"email": user.primary_email_address,
"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 in Section 10d next to the webhook sync handler. 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 follow the org:<feature>:<permission> dashboard key format (e.g., org:invoices:create, org:reports:read). That full string is what you check against in code — but it is not what's literally in the session token. v2 session tokens encode permissions compactly across three claims:
fea— a top-level claim listing enabled features with scope prefixes, e.g."o:dashboard,o:teams".o.per— a comma-separated list of bare permission names shared across features, e.g."manage,read".o.fpm— a comma-separated list of integers, one per feature infea. Each integer is a bitmask: bitj(right-to-left, 1-indexed) indicates whether the permission at indexjofo.perapplies to that feature.
Worked example from the session-tokens guide: if a role has dashboard:read, dashboard:manage, teams:read, the claims are fea="o:dashboard,o:teams", o.per="manage,read", o.fpm="3,2". Bit-decoding 3 = 11 = both manage and read for dashboard. Decoding 2 = 10 = only read for teams. The reconstructed permission list is ["org:dashboard:manage", "org:dashboard:read", "org:teams:read"].
The good news: the Python SDK does this decode for you. authenticate_request() calls an internal _compute_org_permissions() helper that reads fea, o.per, and o.fpm, reconstructs the full org:<feature>:<permission> strings, and writes the result as a plain list at payload["org_permissions"]. Membership testing is straightforward:
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"))],
):
...A caveat: o.rol only carries the role for the active organization — the one referenced by o.id. If the user belongs to multiple organizations and you need to check a specific one other than the active org, or enumerate every membership, 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 carries 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_ISSUER— wrong Clerk instanceTOKEN_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 Request
from fastapi.responses import JSONResponse
from app.main import app
@app.exception_handler(HTTPException)
def http_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={"error": "unauthorized" if exc.status_code == 401 else "forbidden",
"reason": exc.detail},
headers=exc.headers or {},
)A few guardrails to keep in mind. Always include WWW-Authenticate: Bearer on 401s — it's RFC 6750 compliance and some clients expect it. Never log the raw Authorization header or the token itself. Log the rejection reason, the request path, and (if known) the user ID. That's enough to debug without creating a CWE-532 "Insertion of Sensitive Information into Log File" vulnerability.
Testing your FastAPI endpoints
The sync TestClient still works and is the simplest default:
import pytest
from fastapi.testclient import TestClient
from app.auth import require_auth
from app.main import app
def _fake_auth():
class FakeState:
is_signed_in = True
payload = {"sub": "user_fake123", "sid": "sess_fake"}
reason = None
return FakeState()
@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 (as above) is cleaner than patching authenticate_request globally — it leaves the rest of the chain (CORS, middleware, validation) real.
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")Don't over-mock. If you patch the entire auth chain, you'll miss real token parsing bugs. For integration tests against a real Clerk instance, sign in via a dev-instance frontend and grab a session token with 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: 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 or "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. Same as FastAPI, you pass it directly — no wrapping. Flask's EnvironHeaders is a case-insensitive mapping; Clerk's security/types.py just needs headers: Mapping[str, str].
g is Flask's per-request namespace. It's thread-safe (backed by contextvars), scoped to a single request, and the idiomatic place to stash data that multiple handlers need. We put the full state on g.auth_state and the user ID on g.user_id as a shortcut.
functools.wraps. Without it, the decorated function loses its name and Flask's routing breaks (endpoints become wrapper, routes collide). Always wrap.
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", "clerk_webhook"}
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 or "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, e.g. health) rather than request.path (/health) because endpoint names are more stable when you add prefixes or blueprints.
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. Flask applies decorators bottom-up, so the route registration is outermost (@bp.get), then @clerk_required, then @require_permission. Reading top-down: the route registers the view, auth runs next, permission runs last — which is what you want. If you flipped @clerk_required and @require_permission, the permission check would run against an uninitialized g.auth_state and crash.
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 gives you a current_user() API similar in feel to what Flask-Login developers are used to, but pointed at 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 Section 10d.
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 Section 7b...
return wrapperThen in tests/conftest.py:
import pytest
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 = type("S", (), {"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 doesn't ship a first-party Django package in 2026. The clerk/django-example repository exists as a reference but is archived. Community packages (clerk-django 1.0.3, django-clerk 0.1.15) haven't seen updates since 2024 — don't build on them.
The idiomatic Django integration uses Django REST Framework's BaseAuthentication class. DRF's contract is that authenticate() returns a (user, auth) two-tuple, and the user object only has to expose is_authenticated = True to satisfy IsAuthenticated and related permissions. That lets us skip the Django user model entirely — Clerk owns the directory, and a lightweight ClerkUser stub is enough for request authorization.
from dataclasses import dataclass
from clerk_backend_api import AuthenticateRequestOptions, authenticate_request
from django.conf import settings
from rest_framework.authentication import BaseAuthentication
@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:
return None
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."
Register it in settings.py:
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"yourapp.auth.ClerkAuthentication",
],
}Permission checks on a ViewSet:
from rest_framework.permissions import BasePermission
class HasClerkPermission(BasePermission):
def __init__(self, permission: str):
self.permission = permission
def has_permission(self, request, view):
state = request.auth # the RequestState returned by authenticate()
org_permissions = state.payload.get("org_permissions") or []
return self.permission in org_permissionsFor plain Django (not DRF), subclass MiddlewareMixin and populate request.user from the authenticated state inside process_request. Docs: DRF authentication.
Integrating with a React frontend
Python doesn't render sign-in UI. Something has to. The most common frontend pairing with a Python backend is React (or Next.js with React under the hood). Here's the handshake.
Minimal React setup
npm install @clerk/clerk-reactWrap your app:
import { ClerkProvider, SignedIn, SignedOut, SignIn, UserButton } from '@clerk/clerk-react'
const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY
export default function App() {
return (
<ClerkProvider publishableKey={publishableKey}>
<SignedOut>
<SignIn />
</SignedOut>
<SignedIn>
<UserButton />
<ProtectedPage />
</SignedIn>
</ClerkProvider>
)
}That's the whole frontend prerequisite. Full setup: Clerk React quickstart.
Calling your Python API from React
The pattern that does the work:
import { useAuth } from '@clerk/clerk-react'
function ProtectedPage() {
const { getToken } = useAuth()
async function fetchMe() {
const token = await getToken()
const res = await fetch('http://localhost:8000/api/me', {
headers: { Authorization: `Bearer ${token}` },
})
return res.json()
}
return <button onClick={fetchMe}>Load profile</button>
}useAuth().getToken() returns the short-lived (60-second) session JWT. The SDK auto-refreshes roughly every 50 seconds, so a long-lived page stays authenticated as long as the Clerk session is valid. Force a fresh token with getToken({ skipCache: true }) if you're debugging expiry behavior.
CORS on the Python side
If your frontend and Python backend live on different origins (localhost:3000 and localhost:8000 during development, or app.example.com and api.example.com in production), CORS has to allow the request.
FastAPI:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "https://yourapp.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)Flask:
from flask_cors import CORS
CORS(
app,
resources={r"/api/*": {"origins": ["http://localhost:3000", "https://yourapp.com"]}},
supports_credentials=True,
)The gotcha in both frameworks: if you set allow_credentials=True (FastAPI) or supports_credentials=True (Flask), you cannot use "*" for origins. The browser rejects the response. List the origins explicitly, or don't use credentials. For bearer-token auth (Authorization: Bearer ...), you technically don't need credentials mode at all — that's only for cookies.
Same origins you list in CORS_ORIGINS should also appear in CLERK_AUTHORIZED_PARTIES. The two checks serve different layers (CORS protects the browser; azp protects the token), but they have to be consistent or requests fail.
Advanced Clerk SDK features for Python backends
Once the basic auth loop works, these are the SDK corners most teams end up in.
User metadata access
Clerk users have three metadata buckets:
public_metadata— readable from frontend and backend. Use for feature flags, subscription tiers, anything safe to ship to the browser.unsafe_metadata— writable from the frontend SDK. Use for user-controlled preferences.private_metadata— backend-only. Use for sensitive flags, internal IDs, things you don't want the user to see or change.
Reading and updating:
clerk = Clerk(bearer_auth=os.environ["CLERK_SECRET_KEY"])
user = clerk.users.get(user_id="user_xxx")
print(user.public_metadata) # {"subscription_tier": "pro"}
clerk.users.update(
user_id="user_xxx",
public_metadata={"subscription_tier": "enterprise"},
)Common use case: Stripe webhook handler updates public_metadata.subscription_tier after a successful checkout. Frontend reads it from useUser().user.publicMetadata and gates premium features accordingly. No separate database needed for this data.
Session management
List active sessions for a user, revoke one, or revoke all. "Sign out everywhere" is a three-line operation:
sessions = clerk.sessions.list(user_id="user_xxx")
for session in sessions.data:
if session.status == "active":
clerk.sessions.revoke(session_id=session.id)Revoking a session invalidates the refresh token; the user's browser will fail to refresh and be signed out on the next page load. The async equivalent uses clerk.sessions.list_async(...) on the same Clerk instance.
Organizations and team features
Clerk organizations give you B2B multi-tenancy without rolling your own. The Backend API mirrors the frontend SDK:
# Create an org
org = clerk.organizations.create(name="Acme Inc", slug="acme")
# List a user's orgs
memberships = clerk.users.get_organization_memberships(user_id="user_xxx")
# Invite a member
clerk.organization_invitations.create(
organization_id=org.id,
email_address="teammate@example.com",
role="org:admin",
)Roles and permissions management via Backend API shipped on 2025-11-24. You can now create custom roles and permissions programmatically (e.g., during org provisioning) instead of only via the Dashboard:
role = clerk.organization_roles.create(
instance_id="ins_xxx", # your Clerk instance
name="Analyst",
key="org:analyst",
description="Read-only access to reports",
)Webhook handling in Python
Webhooks are how Clerk tells your Python backend about user events — user.created, user.updated, user.deleted, organization events, role changes. Sync them into your database, trigger emails, update analytics, whatever.
Clerk delivers webhooks via Svix. You verify the signature with Svix's Python SDK.
uv add svixFastAPI handler:
from fastapi import APIRouter, HTTPException, Request
from svix.webhooks import Webhook, WebhookVerificationError
from app.config import get_settings
router = APIRouter()
@router.post("/webhooks/clerk")
async def clerk_webhook(request: Request):
body = await request.body()
headers = dict(request.headers)
settings = get_settings()
try:
event = Webhook(settings.clerk_webhook_signing_secret).verify(body, headers)
except WebhookVerificationError:
raise HTTPException(status_code=400, detail="invalid signature")
event_type = event["type"]
data = event["data"]
if event_type == "user.created":
await handle_user_created(data)
elif event_type == "user.deleted":
await handle_user_deleted(data["id"])
return {"ok": True}Flask handler — same shape, different framework primitives:
from flask import Blueprint, abort, current_app, request
from svix.webhooks import Webhook, WebhookVerificationError
bp = Blueprint("webhooks", __name__)
@bp.post("/webhooks/clerk")
def clerk_webhook():
body = request.get_data()
headers = dict(request.headers)
try:
event = Webhook(
current_app.config["CLERK_WEBHOOK_SIGNING_SECRET"]
).verify(body, headers)
except WebhookVerificationError:
abort(400, description="invalid signature")
if event["type"] == "user.created":
handle_user_created(event["data"])
return {"ok": True}A few details to get right the first time.
Use the raw body, not parsed JSON. Signature verification is an HMAC over the raw bytes. If FastAPI or Flask has already parsed the JSON, the HMAC won't match. await request.body() (FastAPI) and request.get_data() (Flask) both return raw bytes.
Required headers. Svix's Webhook.verify() needs svix-id, svix-timestamp, and svix-signature. Clerk also sends the standardized webhook-id, webhook-timestamp, webhook-signature aliases; Svix accepts either. All three headers are always present — they're never optional.
Casing is handled. The Svix Python SDK lowercases headers on entry (see svix/webhooks.py line 15). FastAPI's Request.headers yields lowercase keys already; Flask's EnvironHeaders yields HTTP-canonical casing (Svix-Id) that Svix lowercases immediately. Either way, dict(request.headers) is safe to pass in.
Reverse-proxy gotcha. If your webhook endpoint sits behind Nginx, Cloudflare, or an API gateway, make sure the svix-* (or webhook-*) headers pass through untouched. Some WAFs strip unrecognized headers. If Webhook.verify() throws WebhookHeadersError, log the header keys (not values) to confirm the proxy isn't dropping them.
Replay prevention. Svix ships with a built-in 5-minute timestamp window. Requests older than that fail verification automatically.
Idempotency. Persist processed svix-id values for about 24 hours; reject duplicates. Svix guarantees at-least-once delivery, so you will see the same event twice occasionally.
The webhook endpoint must bypass auth. Don't put @clerk_required or global auth middleware in front of it. Svix signs with a different secret than session tokens; authenticate_request() on the same request would reject it.
Store Clerk's id as a foreign key, not a primary key. Keep your own integer primary keys; make clerk_id a unique, indexed, non-null string column. This is what Clerk's syncing guide recommends.
A minimal SQLAlchemy 2.0 User model to back the webhook handler:
from sqlalchemy import String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
clerk_id: Mapped[str] = mapped_column(String(255), unique=True, index=True)
email: Mapped[str | None] = mapped_column(String(320))
subscription_tier: Mapped[str] = mapped_column(String(32), default="free")About String(255). Clerk user IDs are currently 32 characters (user_ + 27 word chars per the OpenAPI spec pattern ^user_\w{27}$), but the schema component for User.id is plain type: string with no maxLength or pattern constraint. The Python SDK types it as plain str. Clerk's own public-API fields that carry user IDs use maxLength: 255. PostgreSQL stores VARCHAR(n) and VARCHAR(255) identically on disk. Tightening to String(32) saves nothing and creates a silent-failure risk if Clerk ever lengthens the ID format. Keep String(255).
The user.created handler:
async def handle_user_created(data: dict):
clerk_id = data["id"]
email = None
for address in data.get("email_addresses") or []:
if address["id"] == data.get("primary_email_address_id"):
email = address["email_address"]
break
async with async_session() as session:
user = User(clerk_id=clerk_id, email=email)
session.add(user)
await session.commit()The user.deleted handler is symmetric:
async def handle_user_deleted(clerk_id: str):
async with async_session() as session:
user = await session.scalar(
select(User).where(User.clerk_id == clerk_id)
)
if user:
await session.delete(user)
await session.commit()Source: Svix receiving webhooks with FastAPI, Svix receiving with Flask, Svix verification internals.
User impersonation for support
Support workflows often need "sign in as this user to see what they see." Clerk calls this feature actor tokens. The Python SDK exposes it as clerk.actor_tokens.create(...), which hits POST /v1/actor_tokens on the Backend API. The request body takes two mandatory fields: the user being impersonated (user_id) and the admin doing the impersonating (actor.sub). The admin is embedded in the issued session's act claim so the audit trail shows who did what.
actor_token = clerk.actor_tokens.create(request={
"user_id": "user_target_xxx", # user being impersonated
"actor": {"sub": "user_admin_xxx"}, # admin doing the impersonating (ends up in `act`)
"expires_in_seconds": 3600, # optional, default 1 hour
"session_max_duration_in_seconds": 1800, # optional, default 30 minutes
})
# Share the one-time sign-in URL with the support agent.
# Visiting it signs them in as the target user; the resulting session
# carries `act.sub = "user_admin_xxx"` so your audit logs track it back.
print(actor_token.url)
print(actor_token.token) # raw ticket value if you want to build the URL yourselfRevoke before expiry with clerk.actor_tokens.revoke(actor_token_id=actor_token.id).
Do not confuse actor_tokens with sign_in_tokens — the latter is a one-time sign-in link for a normal user (no act claim, no audit trail), not an impersonation primitive. Full guide: user impersonation.
Machine-to-machine authentication
When a service calls another service (not on behalf of a user), you want M2M tokens. Clerk supports two formats:
- Opaque (
m2m_xxx) — network verification, revocable. Best when you want to invalidate a token immediately. - JWT (
mt_xxx) — networkless verification, not revocable until expiry. Best for high-throughput service-to-service calls. Shipped 2026-02-24.
Restrict an endpoint to M2M traffic only:
state = authenticate_request(
request,
AuthenticateRequestOptions(
secret_key=settings.clerk_secret_key,
accepts_token=["m2m_token"],
),
)API Keys (user-scoped programmatic access, prefix ak_) went GA on 2026-04-17. If you want both session tokens and API keys to work on the same endpoint, pass accepts_token=["session_token", "api_key"]. Full guide: machine auth overview.
Production deployment considerations
Everything below is the stuff that breaks on the first 2 a.m. page if you skip it.
Secret management
Never commit .env. .gitignore it and commit an .env.example with the variable names but no values.
Rotation plan for CLERK_SECRET_KEY: create a new secret key in the Dashboard, deploy it to production, wait one deploy cycle, delete the old one. Clerk accepts both during the window. Rotate annually or on suspected compromise. Keep CLERK_JWT_KEY (public) separate from CLERK_SECRET_KEY (private) in your secret store; the JWT key can live in plain environment config, but the secret key should be in a proper secret manager.
Options: AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Doppler, Infisical. Railway, Render, Fly.io, and Vercel all have encrypted-at-rest environment variable storage that's sufficient for small teams. Pick whichever fits your platform; the pattern (inject at boot, never log) is the same.
CORS and authorized parties
Repeat of the gotcha from Section 9: allow_origins=["*"] + allow_credentials=True breaks every browser. List origins explicitly.
authorized_parties on AuthenticateRequestOptions is your CSRF backstop. Even if CORS is misconfigured, azp validation rejects tokens issued for origins you didn't list. It's a defense-in-depth check, not a substitute for CORS.
Subdomain strategies:
- Same-apex (
app.example.com+api.example.com) — same-site cookies work if you set the cookie domain to.example.com. SameSite=Lax is fine for most flows. - Cross-origin (
yourapp.com+api.someothercompany.com) — you're in full cross-origin territory. UseAuthorization: Bearer ..., don't bother with cookies, and be strict about CORS origins.
Performance and caching
Networkless verification with jwt_key is one PEM decode per process, cached in memory. Each subsequent request is a local RS256 signature check — no network round-trip to Clerk.
Networked verification fetches JWKS from https://api.clerk.com/v1/jwks once per process per kid. Cached in memory. Re-fetches on signature mismatch (key rotation). The first request after startup pays a one-time network round-trip; every request after that is a local signature check against the cached key.
Don't call sdk.users.get() on every request. The session token already has sub, sid, and the org claims. Only fetch the full user when you need metadata you can't get from the token.
Reuse a single Clerk() instance at module scope. The SDK is httpx-based, and reusing the underlying client means you benefit from HTTP connection pooling. The httpx clients guide documents this trade-off directly: without a long-lived Client, httpx has to "establish a new connection for every single request," while a reused client brings "reduced latency across requests (no handshaking)." Creating a new Clerk() per request is the pattern to avoid.
Observability and logging
What to log per authenticated request: timestamp, user_id, session_id, request ID, endpoint, outcome (allowed / rejected), latency, rejection reason if any. That's enough to debug almost anything.
What to never log: the full token, the raw Authorization header, password hashes, PII like email addresses unless you have a reason.
Structured logging with structlog (preferred) or loguru (popular but set diagnose=False in production — it can leak secrets into tracebacks).
OpenTelemetry instrumentation for FastAPI and Flask: opentelemetry-instrumentation-fastapi, opentelemetry-instrumentation-flask, and opentelemetry-instrumentation-httpx (to trace outbound Clerk Backend API calls). Set OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS=authorization,cookie,svix-signature so the tracing layer doesn't exfiltrate credentials to your APM.
Deployment platforms
Short rundowns of where Python APIs actually run in 2026.
Railway — railway.json or railway.toml for config; startCommand for the worker command. Railway's Railpack builder defaults to Python 3.13; pin a different version with .python-version.
Render — uvicorn app.main:app --host 0.0.0.0 --port $PORT for FastAPI or gunicorn "app:create_app()" -b 0.0.0.0:$PORT for Flask. Free tier sleeps after 15 minutes idle; the Starter plan is $7/month for always-on.
Fly.io — fly secrets set CLERK_SECRET_KEY=... writes to an encrypted vault; the API servers can only encrypt, not decrypt, stored secret values. Python docs at fly.io/docs/python.
Heroku — Procfile: web: gunicorn -k uvicorn_worker.UvicornWorker app.main:app. Newly created Python apps default to the latest patch of Python 3.14; pin explicitly via .python-version.
Google Cloud Run — Dockerfile with gunicorn on $PORT; --set-env-vars or pull from Secret Manager with roles/secretmanager.secretAccessor. The free tier includes 180,000 vCPU-seconds and 2 million requests per month under request-based billing (240,000 vCPU-seconds under instance-based billing).
AWS Lambda via Mangum — Mangum(app, lifespan="off"). Initialize Clerk() and httpx.Client at module level so warm invocations reuse them. Lambda SnapStart is available for Python 3.12 and later to reduce cold-start latency. Mangum on PyPI.
Vercel Python Functions — Supports Python 3.12 (default), 3.13, and 3.14. Auto-detects FastAPI and Flask from requirements.txt. Good fit when your frontend is already on Vercel.
Cold-start concerns matter on serverless (Lambda, Cloud Run, Vercel). Networkless jwt_key mode eliminates the JWKS warmup cost — one less thing to worry about on the first request after a scale-up.
Frontend-backend communication in production
Same-origin deployment: use Next.js 16's proxy.ts to proxy /api/* internally to your Python service via rewrites. proxy.ts replaces the deprecated middleware.ts as the top-level file in Next.js 16 (released 2025-10-21). Clerk's import path is unchanged — the function is still clerkMiddleware() from @clerk/nextjs/server; only the file name changed. Next.js 15 and earlier projects keep the file as middleware.ts.
Cross-origin deployment (SPA at yourapp.com, Python API at api.yourapp.com): no file-rename concern. Rely on authorized_parties on the Python side and an explicit CORSMiddleware allow-list.
Cookie SameSite=Lax for same-origin; bearer tokens for cross-origin. Don't try to make cookies work across third-party domains — browsers are actively removing third-party cookie support and you'll spend weeks debugging.
Clerk production instance checklist
Canonical checklist: deploy to production.
Clerk vs. other Python authentication options
The options at a glance. Pricing and free-tier numbers are verified against vendor pricing pages in April 2026; capability rows cite a primary vendor source.
Numbers sourced from each vendor's pricing and compliance page (links in table), the Clerk "new plans, more value" changelog (2026-02-05 repricing), and WorkOS's 2026 guide for the DIY cost range.
Clerk's tier detail, since numbers move:
- Hobby (free) — 50,000 MRUs. Basic RBAC (20-member org cap), 5 impersonations/month, 3 dashboard seats, 7-day fixed sessions. Does not include MFA, passkeys, or Enterprise SSO.
- Pro — $20/month billed annually ($25 monthly). Adds MFA, passkeys, custom email templates, custom session duration, SMS codes, satellite domains ($10/mo each), remove Clerk branding, and 1 Enterprise SSO connection included ($75/mo per additional connection).
- Business — $250/month billed annually ($300 monthly). Adds SOC 2 Report access, HIPAA artifact access, 10 dashboard seats (additional $20/mo each), enhanced dashboard roles, and priority support.
- Enterprise — custom pricing, annual only. Adds HIPAA compliance available with BAA, 99.99% uptime SLA, premium support, dedicated onboarding, custom Slack channel, annual committed-use discounts, and Enterprise SSO for the workspace.
When to pick Clerk
- You want a first-party Python SDK with predictable release cadence.
- You need strong B2B / organizations support, including programmatic role and permission management.
- You already use React, Next.js, or Expo — Clerk's frontend components match the backend one-to-one.
- You want passkeys, MFA, and social OAuth without building them.
- You need SOC 2 Report access on the Business tier, or HIPAA BAA availability on Enterprise, for compliance reasons.
When another option might fit better
Supabase if your whole stack is Supabase — Postgres plus realtime plus storage plus auth, all consolidated. The client-SDK focus on auth means you'll write more Python verification code manually, but you get a tight DB integration.
Firebase if you're deep in Google Cloud — Firestore, Cloud Functions triggers, App Check integration. The organizations gap matters if your app is B2B.
Auth0 if you need extensive enterprise SSO beyond what Clerk offers in Pro. Auth0's Enterprise tier has more granular SAML/OIDC controls. Pricing starts at $35/month (7,500 MAU free) and scales up; the old "$1,600/mo at 10K MAU" number you might see in older comparisons was a briefly-available 2024 promotion that has rolled back.
Cognito if you're AWS-native with no other preference. You'll write more Python yourself (no dedicated FastAPI/Flask helper), but Lambda Triggers compose well with the rest of your stack.
DIY only if authentication is literally your product (you're building an IDP), or for learning. The time and compliance cost is hard to justify otherwise.
None of these are bad choices. Clerk is the default recommendation for a modern Python API paired with a modern frontend; the others fit legitimate niches.