
How to Add Authentication to a Python Backend - Part 3
Part 3 of 3. Start with How to Add Authentication to a Python Backend.
This is the final part of our guide on adding authentication to a Python backend. In Part 1, we covered the foundational concepts and setting up Clerk. In Part 2, we built concrete implementations using FastAPI and Flask. Now, in Part 3, we connect our Python API to a React frontend, explore advanced Clerk SDK features like webhooks and organizations, prepare for production deployment, and compare Clerk against other Python authentication options.
Integrating with a React frontend
Python doesn't render sign-in UI. React (or Next.js) is the most common frontend pairing. Here's the handshake.
Minimal React setup
npm install @clerk/reactWrap your app:
import { ClerkProvider, Show, SignIn, UserButton } from '@clerk/react'
const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY
export default function App() {
return (
<ClerkProvider publishableKey={publishableKey}>
<Show when="signed-out">
<SignIn />
</Show>
<Show when="signed-in">
<UserButton />
<ProtectedPage />
</Show>
</ClerkProvider>
)
}<Show when="..."> is Clerk's current auth-state gate — it replaces the older <SignedIn> / <SignedOut> (and <Protect>) components, which are deprecated in Clerk's Core 3 SDKs (@clerk/react).
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/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:5173 and localhost:8000 during development, or app.example.com and api.example.com in production), CORS has to allow the request. The React example above uses Vite, which serves on port 5173; Next.js and Create React App default to 3000, so match allow_origins to whatever URL your dev server prints on startup.
FastAPI:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:3000", "https://yourapp.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)Flask, via the flask-cors extension (uv add flask-cors):
from flask_cors import CORS
CORS(
app,
resources={r"/api/*": {"origins": ["http://localhost:5173", "http://localhost:3000", "https://yourapp.com"]}},
supports_credentials=True,
)The gotcha: if you enable credentials, you cannot use "*" for origins. The browser rejects the response. List origins explicitly. For bearer-token auth (Authorization: Bearer ...), you don't need credentials mode — that's only for cookies.
Your CORS_ORIGINS should also appear in CLERK_AUTHORIZED_PARTIES. The checks serve different layers (CORS protects the browser; azp protects the token), but they must be consistent.
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_metadata(
user_id="user_xxx",
public_metadata={"subscription_tier": "enterprise"},
)Metadata has its own dedicated methods — update_metadata() merges (patch) and replace_metadata() overwrites (added in SDK v6.0.0). The general clerk.users.update() no longer accepts metadata fields as of the 2026-05-12 API version, so reach for these instead.
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, revoke one, or revoke all. "Sign out everywhere":
sessions = []
offset = 0
while True:
page = clerk.sessions.list(user_id="user_xxx", status="active", limit=100, offset=offset)
if not page:
break
sessions.extend(page)
offset += 100
for session in sessions:
clerk.sessions.revoke(session_id=session.id)Mind the pagination: sessions.list() defaults to limit=10, so a bare call quietly misses sessions for users signed in on many devices. Collect every page first, then revoke.
Revoking a session marks it revoked server-side. Clerk issues no long-lived refresh token — just short 60-second JWTs — so the user is signed out within about a minute, on the next token refresh. The async equivalent is clerk.sessions.list_async(...).
Organizations and team features
Clerk organizations provide B2B multi-tenancy. The Backend API mirrors the frontend SDK:
# Create an org
org = clerk.organizations.create(request={"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 shipped on 2025-11-24. Create custom roles programmatically instead of only via the Dashboard:
role = clerk.organization_roles.create(
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}Details to get right:
Use the raw body. Signature verification is an HMAC over raw bytes. If FastAPI/Flask parses the JSON, the HMAC won't match. await request.body() and request.get_data() return raw bytes.
Required headers. Webhook.verify() needs svix-id, svix-timestamp, and svix-signature (or their webhook-* aliases). They're never optional.
Casing is handled. The Svix SDK lowercases headers on entry. 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. Svix guarantees at-least-once delivery, so you will see the same event twice occasionally. Persist processed svix-id values for about 24 hours and acknowledge duplicates with a 2xx — a non-2xx response just triggers more retries. The user.created handler below gets the same effect more simply by checking for an existing clerk_id before inserting.
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.
Test locally before deploying. Clerk delivers webhooks over the public internet, so expose your local server with a tunnel — Clerk's guide uses ngrok (ngrok http 8000), and localtunnel, Cloudflare Tunnel, or Pinggy work too — then register the forwarding URL plus your route (https://<id>.ngrok-free.app/webhooks/clerk) as the endpoint on the Webhooks page in the Clerk Dashboard and copy that endpoint's signing secret (each endpoint has its own) into CLERK_WEBHOOK_SIGNING_SECRET. To fire a payload without creating real data, open the endpoint's Testing tab, pick an event like user.created, and click Send Example. The Svix CLI is a no-Dashboard alternative: svix listen http://localhost:8000/webhooks/clerk prints a public URL and relays every delivery to your handler.
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:
existing = await session.scalar(
select(User).where(User.clerk_id == clerk_id)
)
if existing:
return # duplicate delivery — already processed, acknowledge with 2xx
session.add(User(clerk_id=clerk_id, email=email))
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 impersonation. Clerk calls this actor tokens. The Python SDK exposes clerk.actor_tokens.create(...). It takes two mandatory fields: the user (user_id) and the admin (actor.sub). The admin is embedded in the session's act claim for the audit trail.
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) — revocable. Best when you want to invalidate a token immediately. - JWT (
mt_xxx) — a signed JWT, not revocable until expiry. Shipped 2026-02-24.
One Python-specific caveat: the current Python SDK verifies both formats with a call to Clerk's API — verify_token routes every machine token through network verification, and passing jwt_key doesn't change that path (it only affects session tokens). The networkless upside of the JWT format applies to SDKs that verify machine JWTs locally; in Python, plan for a network round-trip either way.
Restrict an endpoint to M2M traffic only:
state = authenticate_request(
request,
AuthenticateRequestOptions(
secret_key=settings.clerk_secret_key,
# "machine_token" accepts opaque m2m_ tokens; "m2m_token" accepts JWT mt_ tokens
accepts_token=["machine_token", "m2m_token"],
),
)The accepts_token values don't match the prefixes one-to-one: machine_token is the opaque m2m_ format and m2m_token is the JWT mt_ format. List both to accept either while still rejecting session tokens, or just one to lock the endpoint to a single format.
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
These break on the first 2 a.m. page if you skip them.
Secret management
Never commit .env. .gitignore it and commit an .env.example.
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 CORS gotcha from the React-integration section above: 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. Reusing the underlying httpx client allows HTTP connection pooling and avoids handshaking for every request. Creating a new Clerk() per request is an anti-pattern.
Observability and logging
Log per request: timestamp, user_id, session_id, request ID, endpoint, outcome, latency, rejection reason.
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 (additional connections from $75/mo, with volume discounts at higher counts).
- 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 and migration support, custom Slack channel, and annual committed-use discounts.
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 for Essentials (25,000 MAU free on the base plan) and scales up as you add MAUs and enterprise features.
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.
Conclusion
Adding authentication to a Python backend doesn't have to mean reinventing the wheel with manual token validation or maintaining your own password reset flows. By leveraging Clerk's Python SDK, you can secure your FastAPI or Flask applications efficiently while offloading the complex frontend user management. From basic React integrations to advanced webhook handling, machine-to-machine auth, and production-ready deployments, you now have a comprehensive blueprint for securing your Python APIs.
FAQ
In this series
- How to Add Authentication to a Python Backend
- How to Add Authentication to a Python Backend - Part 2
- How to Add Authentication to a Python Backend - Part 3 (you are here)