---
title: Python
description: Verify sessions, manage users and organizations, receive webhooks and serve server-rendered pages from any Python app, sync or async.
---

```bash
pip install zevauth
```

Python 3.9 or later. Using a framework? Read [FastAPI](/sdk/fastapi/),
[Django](/sdk/django/) or [Flask](/sdk/flask/) instead. They build on
everything here and wire it into dependencies, middleware and decorators
for you.

## Who is signed in

```python
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

```python
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](/concepts/mfa/). 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

```python
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:

```python
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](/concepts/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.

```python
zevauth = ZevAuth(
    "env_live_…",
    publishable_key="pk_live_…",       # for the handshake page
    session_bridge=True,
)
```

Then point the browser SDK at the endpoint:

```js
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:

```python
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`:

```python
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"],
    ),
)
```

> **Note:**
> Your server never refreshes a session. Only the browser does, and only
> access tokens reach your server. That is what lets the bridge work
> alongside the browser SDK without either one signing the person out.

## Backend API

```python
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:

```python
from zevauth import AsyncZevAuthClient, ZevAuthClient

client = ZevAuthClient(os.environ["ZEVAUTH_SECRET_KEY"])
```

### Errors and retries

```python
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

```python
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](/concepts/server-sdks/) explains what that guarantees.

Source and issues: [github.com/zevop/zevauth-python](https://github.com/zevop/zevauth-python).
