Your backend
Python
Verify sessions, manage users and organizations, receive webhooks and serve server-rendered pages from any Python app, sync or async.
pip install zevauth
Python 3.9 or later. Using a framework? Read FastAPI, Django or Flask instead. They build on everything here and wire it into dependencies, middleware and decorators for you.
Who is signed in
import os
from zevauth import Request, ZevAuth
zevauth = ZevAuth(
os.environ["ZEVAUTH_ENVIRONMENT_ID"], # env_live_…
secret_key=os.environ["ZEVAUTH_SECRET_KEY"], # only for the backend API
)
auth = zevauth.get_auth(Request("GET", url, headers)) # any framework
auth = await zevauth.get_auth_async(request) # asyncio servers
if auth.is_signed_in:
auth.user_id # user_…
auth.organization_id # org_…, or None
auth.has(permission="org:members:manage")
Create one ZevAuth when your app starts and reuse it. It caches your
environment’s signing keys, so verifying a request does not touch the
network.
The token comes from the Authorization: Bearer … header, or from the
session-bridge cookie for server-rendered pages (below). Request takes
the method, the full URL, the headers and (only for the session bridge
endpoint) the raw body. The framework integrations build it for you.
get_auth() never raises. A missing, expired or tampered token means
nobody is signed in, so a page can always decide what to render.
Refusing a request
from zevauth import AuthRefusal
try:
auth = zevauth.require_auth(request, require_mfa=True)
except AuthRefusal as refused:
return json_response(refused.body(), status=refused.status)
| Status | error.code | When |
|---|---|---|
| 401 | unauthorized | Not signed in |
| 403 | session_task_pending | The person must finish a task first, such as setting up two-step verification. The body lists pendingTasks |
| 403 | mfa_required | You passed require_mfa=True and they haven’t verified a second factor |
Pass allow_pending_tasks=True only on the routes that help someone finish
a task. require_auth_async is the same for asyncio.
Verifying a token yourself
from zevauth import ZevAuthVerifyError
try:
claims = zevauth.verify_token(token) # or: await zevauth.verify_token_async(token)
except ZevAuthVerifyError as error:
error.code # token_expired, invalid_audience, …
Only RS256 is accepted, the audience must be your environment, and clocks may differ by up to five seconds. A token from your test environment is refused on your live one.
For tests, or a server with no outbound network, pass the key set yourself:
zevauth = ZevAuth("env_test_…", jwks=key_set, issuer="https://api.zevauth.net/v1/env_test_…")
Server-rendered pages
A page requested by the browser carries no Authorization header. The
session bridge gives your server a cookie to
read instead: the browser SDK posts its access token to an endpoint on
your site, which sets your own HttpOnly cookie.
zevauth = ZevAuth(
"env_live_…",
publishable_key="pk_live_…", # for the handshake page
session_bridge=True,
)
Then point the browser SDK at the endpoint:
ZevAuthUI.init({ publishableKey: 'pk_live_…', sessionBridge: { url: '/zevauth/session' } });
Each framework integration mounts the two routes for you
(/zevauth/session and /zevauth/handshake). Without one, serve them
yourself:
response = zevauth.handle_session_bridge(request) # POST /zevauth/session
response = zevauth.handshake_page(request) # GET /zevauth/handshake
auth, handshake = zevauth.authenticate(request) # on every page
if handshake is not None:
return handshake # a redirect, or a 401 for API calls
Every one returns a Response with status, headers (a list, because
Set-Cookie repeats) and body.
To change paths, share cookies across subdomains or serve your own copy of
the handshake script, pass SessionBridgeOptions:
from zevauth import SessionBridgeOptions
zevauth = ZevAuth(
"env_live_…",
publishable_key="pk_live_…",
session_bridge=SessionBridgeOptions(
cookie_domain="example.com",
allowed_origins=["https://app.example.com", "https://www.example.com"],
),
)
Backend API
user = zevauth.api.users.create(email="ada@example.com", first_name="Ada")
zevauth.api.users.update(user["id"], public_metadata={"plan": "pro"}, last_name=None)
for user in zevauth.api.users.iterate(metadata={"plan": "pro"}):
...
zevauth.api.users.reset_mfa(user["id"], revoke_sessions=True)
zevauth.api.organizations.add_member("org_…", user_id=user["id"], role="org:admin")
Keyword arguments are snake_case; they go out in the API’s camelCase. In
update, None clears a field and leaving it out keeps it.
zevauth.api_async is the same API for asyncio, with async for over
iterate(). You can also build the clients directly:
from zevauth import AsyncZevAuthClient, ZevAuthClient
client = ZevAuthClient(os.environ["ZEVAUTH_SECRET_KEY"])
Errors and retries
from zevauth import ZevAuthError
try:
zevauth.api.users.get("user_…")
except ZevAuthError as error:
error.code # not_found, rate_limited, network_error, …
error.status # 404, 429, 0 when no response arrived
error.request_id # quote it to ZevAuth support
Rate limits (429) and gateway errors (502, 503, 504) are retried twice,
waiting for Retry-After when the API sends it. A 500 is never retried:
the request may already have run, and retrying is how one user becomes two.
Webhooks
from zevauth import ZevAuthWebhookError, construct_webhook_event
try:
event = construct_webhook_event(
raw_body, # bytes, exactly as received
headers.get("zevauth-signature"),
os.environ["ZEVAUTH_WEBHOOK_SECRET"], # whsec_…
)
except ZevAuthWebhookError:
return 400
if event.type == "user.created":
...
Verify the raw body. The signature covers the exact bytes ZevAuth sent,
so a body that was decoded and re-encoded fails, correctly. Each framework
page shows how to get the raw bytes. Deliveries can arrive more than once:
use the zevauth-delivery-id header or event.id to handle each one once.
The same in every language
This library implements the same contract as the ZevAuth libraries for Node.js, PHP, Ruby, Go and Java, and passes the same conformance tests. Server SDKs explains what that guarantees.
Source and issues: github.com/zevop/zevauth-python.
Updated at, Sunday, November 1, 2026