Skip to main content

stwrd-auth

PyPI version Python versions License: MIT Types included

Python SDK for stwrd: OpenID Connect sign-in, server-side sessions, FastAPI dependencies and webhook verification.

The package implements the backend-for-frontend (BFF) profile. It mounts the /auth/* routes on a FastAPI app, keeps the session on the server and exposes dependencies to protect your own routes. The browser only receives a signed cookie; access, refresh and ID tokens never leave your process. The framework-independent parts (OIDC client, session store, webhook verification, Management API client) work without FastAPI.

This is the Python counterpart of @stwrd-auth/node: same routes, same responses, same /auth/session JSON and same environment variables. The browser client is @stwrd-auth/react.

Installation

The package is installed as stwrd-auth and imported as stwrd.

# with uv
uv add "stwrd-auth[fastapi]"

# with pip
pip install "stwrd-auth[fastapi]"
Extra Adds Needed for
fastapi fastapi, starlette, python-multipart stwrd.fastapi: the router, the dependencies and protect()
postgres psycopg[pool] stwrd.postgres.PostgresSessionStore, a session store shared by several workers

Without extras you get the OIDC client, the session types, webhook verification and the Management API client, which depend only on httpx and joserfc.

Requires Python 3.12 or newer.

Quick start

Register an application in stwrd, allow {STWRD_BASE_URL}/auth/callback as a redirect URI, and export the required settings:

export STWRD_ISSUER=https://idp.example.com
export STWRD_CLIENT_ID=your-client-id
export STWRD_CLIENT_SECRET=your-client-secret
export STWRD_BASE_URL=https://app.example.com
export STWRD_COOKIE_SECRET="$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"

Then create the app:

from contextlib import asynccontextmanager

from fastapi import Depends, FastAPI

from stwrd import Stwrd, StwrdUser
from stwrd.fastapi import auth_router, current_user, optional_user

stwrd = Stwrd.from_env()


@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    await stwrd.close()


app = FastAPI(lifespan=lifespan)
app.include_router(auth_router(stwrd))


@app.get("/")
async def home(user: StwrdUser | None = Depends(optional_user(stwrd))):
    if user is None:
        return {"signed_in": False, "sign_in": "/auth/sign-in?return_to=/me"}
    return {"signed_in": True, "email": user.email}


@app.get("/me")
async def me(user: StwrdUser = Depends(current_user(stwrd))):
    return {"id": user.id, "email": user.email, "roles": user.roles}

Run it with uvicorn main:app. Opening /auth/sign-in?return_to=/me redirects to stwrd, and /auth/callback completes the sign-in and returns the person to /me. GET /auth/session returns the JSON that @stwrd-auth/react consumes:

{"authenticated": true,
 "user": {"id": "…", "email": "…", "email_verified": true,
          "display_name": null, "avatar_url": null, "roles": [], "permissions": []},
 "organization": null, "consents": {},
 "csrf_token": "…", "account_url": "https://idp.example.com/me"}

Roles, permissions, sign-out and webhooks

from fastapi import Depends, FastAPI, Request
from fastapi.responses import HTMLResponse

from stwrd import Stwrd, StwrdUser, WebhookEvent
from stwrd.fastapi import (
    auth_router, current_user, optional_user, require_permission, require_role,
)

stwrd = Stwrd.from_env()


async def on_event(event: WebhookEvent) -> None:
    # `event.type` is the event name; `event.data`, its payload.
    print("webhook", event.type, event.data)


app = FastAPI()
app.include_router(auth_router(stwrd, on_event=on_event))


@app.get("/", response_class=HTMLResponse)
async def home(user: StwrdUser | None = Depends(optional_user(stwrd))):
    if user is None:
        return '<a href="/auth/sign-in?return_to=/private">Sign in</a>'
    return f'<p>Hello, {user.email}.</p><a href="/private">Private area</a>'


@app.get("/private", response_class=HTMLResponse)
async def private(request: Request, user: StwrdUser = Depends(current_user(stwrd))):
    # Sign-out is a POST with a CSRF token derived from the local session
    # (the same value `GET /auth/session` returns as `csrf_token`).
    session = await stwrd.session_from_cookie(request.cookies.get(stwrd.config.session_cookie))
    csrf = stwrd.csrf(session.id)
    return (
        f"<p>Welcome, {user.email}. Your id is {user.id}.</p>"
        '<form method="post" action="/auth/sign-out">'
        f'<input type="hidden" name="csrf_token" value="{csrf}"><button>Sign out</button></form>'
    )


@app.get("/admin")
async def admin(user: StwrdUser = Depends(require_role(stwrd, "org:admin"))):
    return {"actor_id": user.id}


@app.post("/invitations")
async def invite(user: StwrdUser = Depends(require_permission(stwrd, "members:invite"))):
    return {"invited_by": user.id}

org:admin and members:invite are examples: use the role and permission names defined in your tenant. Organization roles need the org scope, see Scopes.

Without a framework

Webhook verification does not depend on FastAPI. This receiver works with any web framework: pass it the raw request body and the headers.

import os
from collections.abc import Mapping

from stwrd import DuplicateEventError, InvalidSignatureError, verify_webhook
from stwrd.webhooks import SeenWebhookIds

WEBHOOK_SECRET = os.environ["STWRD_WEBHOOK_SECRET"]
seen = SeenWebhookIds()  # in-memory; keep your own record of processed ids too


def receive(body: bytes, headers: Mapping[str, str]) -> int:
    """Return the HTTP status code to answer with."""
    try:
        event = verify_webhook(body, headers, WEBHOOK_SECRET, seen=seen)
    except DuplicateEventError:
        return 200  # already processed
    except InvalidSignatureError:
        return 400
    print("webhook", event.type, event.data)
    return 200

Sessions work the same way outside FastAPI: await stwrd.resolve_session(cookie_value) returns the current StwrdSession or None. See the API reference.

Configuration

Stwrd.from_env() and StwrdConfig.from_env() read these variables. The same names are read by @stwrd-auth/node, so an app can switch languages without touching its .env. Every setting is also a field of StwrdConfig, and keyword arguments to from_env() override the environment.

Variable Required Default Description
STWRD_ISSUER yes Issuer URL of the tenant, for example https://idp.example.com.
STWRD_CLIENT_ID yes OIDC client registered for this application.
STWRD_CLIENT_SECRET yes Secret of that client.
STWRD_BASE_URL yes Public URL of this app. {base_url}{prefix}/callback is the redirect URI.
STWRD_COOKIE_SECRET yes HMAC key for the session cookie, at least 32 characters.
STWRD_SCOPE no openid profile email offline_access Requested scopes, see Scopes.
STWRD_WEBHOOK_SECRET no Signing secret for /auth/webhook. Without it the route answers 503.
STWRD_PREFIX no /auth Path prefix of the routes.
STWRD_SESSION_TTL_S no 28800 (8 h) Sliding lifetime of the local session.
STWRD_COOKIE_SECURE no true Set to false only for plain-HTTP local development.
STWRD_POST_LOGIN_REDIRECT no / Where to go after sign-in when no return_to was given.
STWRD_POST_LOGOUT_REDIRECT no / Where to go after sign-out.
STWRD_CONNECT_TO no Connect elsewhere while keeping the issuer's Host, see Developing against a local server.

If a required variable is missing or STWRD_COOKIE_SECRET is too short, from_env() raises ConfigError and the process does not start. That is on purpose: a misconfigured integration fails at startup, not on the third request.

Fields without a variable: session_cookie (default __Host-stwrd_session), tx_cookie (default __Host-stwrd_tx) and transaction_ttl_s (default 600). Browsers reject a __Host- cookie that is not Secure, so when you set STWRD_COOKIE_SECURE=false for local development also pass cookie names without that prefix: Stwrd.from_env(session_cookie="stwrd_session", tx_cookie="stwrd_tx").

Routes

auth_router(stwrd) mounts these routes under the prefix (default /auth):

Route Purpose
GET /sign-in?return_to=… Starts the authorization-code flow with PKCE. return_to accepts only a relative path of this app; anything else falls back to /.
GET /callback Completes sign-in, creates the local session and sets the cookie.
POST /sign-out Ends the local session and signs out at stwrd. Requires the CSRF token (csrf_token form field or X-CSRF-Token header).
GET /session Current session as JSON (anonymous shape when there is none).
GET /organizations, POST /organization List the person's organizations and switch the active one. Available only when the org scope is requested.
POST /back-channel Receives back-channel logout notices from stwrd.
POST /webhook Receives and verifies webhooks.

auth_router also accepts two hooks: on_user_registered(user), called on every successful sign-in (make it an idempotent upsert), and on_event(event), called once per accepted webhook delivery. Both may be sync or async.

Protecting routes

All dependencies are factories: they take the Stwrd instance and return the callable FastAPI runs under Depends(...). There is no implicit registration in app.state.

Factory Returns If it does not hold
optional_user(stwrd) StwrdUser | None never fails; None means no session
current_user(stwrd) StwrdUser 303 to sign-in, or 401 for API requests
require_auth(stwrd) None 303 to sign-in, or 401 for API requests
require_role(stwrd, role) StwrdUser 403 Missing role {role}.
require_org(stwrd) StwrdUser 403 The session has no organization.
require_permission(stwrd, permission) StwrdUser 403 Missing permission {permission}.
api_mode(stwrd) None makes that route answer a JSON 401 instead of a 303

protect(app, stwrd, public=("/", "/static/*")) is the opt-in fail-closed mode: it requires a session on every path not declared public. Everything under the auth prefix is always public, and * is a wildcard only at the end of a pattern.

require_role and require_permission evaluate the session's coherent organization or individual capability family and never combine the two. require_org raises ConfigError when it is built without the org scope. Dependencies also store request.state.stwrd_user, stwrd_organization and stwrd_consents.

When stwrd does not answer, every entry point responds 503 and keeps the stored session: an unreachable server never looks like a signed-out user.

Scopes

The default scope is openid profile email offline_access. It does not include org; organization claims, require_org and the organization routes need it requested explicitly, in two places:

  1. In the app: STWRD_SCOPE="openid profile email offline_access org".
  2. In stwrd: the scope must be allowed for the client.

Requesting a scope the client is not allowed to use makes the authorization request fail with invalid_scope; it is not trimmed silently. Without offline_access there is no refresh token and the local session lasts as long as the access token.

Sessions and session stores

Tokens and claims are stored server-side in a SessionStore; the cookie only carries an opaque session id. Expired access tokens are renewed with the refresh token on the next request, and a renewal that stwrd rejects ends the session.

Store Use
MemoryStore (default) One process. Sessions are lost on restart and not shared between workers.
stwrd.postgres.PostgresSessionStore Several workers or restarts. Requires the postgres extra.

PostgresSessionStore encrypts the stored tokens (JWE, A256GCM) with a keyring you provide and coordinates refresh across processes, so a refresh token is exchanged at most once. Create the table by running stwrd.postgres.SCHEMA_SQL from your own migration; the store never creates tables.

from psycopg_pool import AsyncConnectionPool

from stwrd import Stwrd
from stwrd.postgres import Keyring, PostgresSessionStore

pool = AsyncConnectionPool("postgresql://user:password@db.example.com/app", open=False)
keyring = Keyring({"k1": key_bytes_32}, current="k1")  # 32 random bytes per key
stwrd = Stwrd.from_env(sessions=PostgresSessionStore(pool, namespace="my-app", keyring=keyring))

Open the pool during application startup (await pool.open()). Any object that implements the SessionStore protocol can be used instead.

Webhooks

auth_router verifies webhooks at POST /auth/webhook when STWRD_WEBHOOK_SECRET is set. To receive them in your own code, or to implement a receiver in another language, see the webhook contract: headers, signature scheme, replay window, retries and a test vector.

Management API

create_management(ManagementOptions(...)) returns a typed, server-side client for the Management API, authenticated with client credentials. Keep these credentials on your server.

import os

from stwrd import ManagementOptions, create_management


async def list_user_ids() -> list[str]:
    options = ManagementOptions(
        issuer=os.environ["STWRD_ISSUER"],
        client_id=os.environ["STWRD_MANAGEMENT_CLIENT_ID"],
        client_secret=os.environ["STWRD_MANAGEMENT_CLIENT_SECRET"],
    )
    async with create_management(options) as management:
        return [user["id"] async for user in management.users.iterate()]

Configuration, ETags and write options, file uploads and cursor traversal are covered in the Management transport guide.

Developing against a local server

The server resolves the tenant from the Host header, so an SDK talking to a server on 127.0.0.1 has to do it as the tenant's host. connect_to (or STWRD_CONNECT_TO) does exactly that for discovery, token, JWKS and userinfo requests: it connects to the given address while keeping the issuer's URL and Host, with no DNS or /etc/hosts changes.

STWRD_ISSUER=https://tenant.example.com
STWRD_CONNECT_TO=http://127.0.0.1:3005

If the app builds its own httpx.AsyncClient (a proxy, a custom transport), connect_to_hook(connect_to) is the same request hook: httpx.AsyncClient(event_hooks={"request": [connect_to_hook(...)]}).

connect_to does not affect the front channel: the browser still has to resolve the issuer's host. For browserless tests, mount the server and the app with httpx.ASGITransport instead.

Running the tests

uv sync --all-extras --all-groups
uv run pytest tests/

The PostgreSQL session-store tests need a PostgreSQL server whose user can create and drop databases (each test module gets its own throwaway database). They look for it at postgresql+psycopg://stwrd:stwrd@localhost:55432/stwrd_test; set STWRD_TEST_DB_URL to point somewhere else. If no server is reachable those tests are skipped with a message saying so, and everything else still runs. Set STWRD_REQUIRE_DB=1 to make an unreachable server a failure instead of a skip, which is what a CI pipeline should do. A throwaway server:

docker run --rm -d --name stwrd-test-pg -p 55432:5432 \
  -e POSTGRES_USER=stwrd -e POSTGRES_PASSWORD=stwrd -e POSTGRES_DB=stwrd_test \
  postgres:16

API reference

Everything below is importable from stwrd unless noted.

Client and configuration

Name Description
Stwrd(config, *, sessions=None, http_client=None) The object an app builds once. Stwrd.from_env(environ=None, **overrides) builds it from the environment.
Stwrd.resolve_session(cookie) Current StwrdSession or None; renews tokens when possible. Raises IdpUnavailable when stwrd cannot be reached.
Stwrd.session_from_cookie(cookie) Raw store lookup: no renewal, no expiry side effects.
Stwrd.csrf(session_id) CSRF token for a session.
Stwrd.seal(value) / Stwrd.unseal(cookie) Sign and verify the opaque cookie value.
Stwrd.verify_webhook(body, headers) Verify a webhook with the configured secret and de-duplicate it.
Stwrd.close() Close the HTTP client, if the instance created it.
StwrdConfig Frozen configuration; fields as in Configuration. StwrdConfig.from_env(), replace(**changes).
connect_to_hook(connect_to) httpx request hook behind connect_to.

Session types and stores

Name Description
StwrdSession id, sub, claims, tokens, expires_at; user, organization and consents projections; has_role() and has_permission().
StwrdUser id, email, email_verified, display_name, avatar_url, roles, permissions.
StwrdOrganization id, display_name, roles, permissions.
Tokens access_token, id_token, token_type, expires_at, refresh_token.
SessionStore Protocol for session storage.
MemoryStore In-process implementation.
stwrd.postgres.PostgresSessionStore, Keyring, SCHEMA_SQL Shared store, see Sessions and session stores.

FastAPI (stwrd.fastapi)

auth_router, optional_user, current_user, require_auth, require_role, require_org, require_permission, api_mode, protect and safe_target. safe_target(candidate, fallback="/") applies the redirect rule used by return_to and is exported for apps that handle redirect targets of their own.

Webhooks

Name Description
verify_webhook(body, headers, secret, tolerance_s=300, *, seen=None) Verify the signature and parse a v1 event into a WebhookEvent.
verify_webhook_signature(body, headers, secret, tolerance_s=300) Verify the signature only and return the event id.
WebhookEvent id, type, api_version, created_at, data.
safe_equal(a, b) Constant-time string comparison.

Management

create_management, ManagementClient, ManagementOptions, WriteOptions, ApiResult and ManagementError; the lower-level ManagementTransport and validate_management_discovery live in stwrd.management.

Security

  • Tokens stay on the server. The browser only holds an HMAC-signed cookie with an opaque session id: __Host- prefixed, HttpOnly, Secure, SameSite=Lax, path /.
  • Authorization code flow with PKCE (S256). state, nonce and the PKCE verifier live in a short-lived signed transaction cookie.
  • ID tokens are verified. Signature (RS256 and EdDSA only, never taken from the token header), iss, aud, exp, nonce and at_hash. Capabilities such as roles and permissions come only from verified ID tokens, never from userinfo.
  • CSRF. sign-out and organization switching require a token derived from the session; GET /auth/session returns it as csrf_token.
  • Open redirects. return_to accepts only an absolute path of this app, and rejects //host, backslash forms, control characters and schemes.
  • Refresh. A refresh token is exchanged at most once across workers when a shared store is used. A silent server never ends a session, and a request that may have consumed a refresh token is never replayed.
  • Webhooks. HMAC-SHA256 over the raw body, constant-time comparison, 300-second replay window and de-duplication by event id.
  • No hand-written cryptography. JOSE operations use joserfc.
  • Secrets. Load STWRD_COOKIE_SECRET, STWRD_CLIENT_SECRET and STWRD_WEBHOOK_SECRET from your secret manager, never from source control. Rotating the cookie secret signs everyone out. Management credentials belong on the server only, and the Management client refuses to follow redirects.
  • Reporting a vulnerability. See the security policy for supported versions and how to report a problem privately.

Errors and troubleshooting

Situation Response
No session, navigation (Accept: text/html) 303 to {prefix}/sign-in?return_to=…
No session, API request (or api_mode) 401 {"detail": "No session."}
Role missing 403 {"detail": "Missing role org:admin."}
Permission missing 403 {"detail": "Missing permission members:invite."}
No organization (require_org) 403 {"detail": "The session has no organization."}
POST /auth/sign-out without a valid CSRF token 403 {"detail": "Invalid CSRF token."}
stwrd does not answer (any route that needs it) 503 {"detail": "The IdP did not respond."}; the session is kept
GET /auth/callback rejected (state, signature, nonce…) 400 plain text
POST /auth/webhook 200 {"status":"ok"} · 200 {"status":"duplicate"} · 400 {"error":"invalid_signature"} · 503 {"error":"webhook_not_configured"}
POST /auth/back-channel invalid 400 {"error": …}
Refresh rejected by stwrd The local session ends; the request looks like "no session".
Incomplete configuration or a short cookie secret ConfigError at construction

Exceptions, all importable from stwrd: ConfigError (invalid configuration), OidcError (stwrd answered and rejected the request), IdpUnavailable (stwrd did not answer: connection error, timeout, 5xx, 408, 425 or 429), RefreshUncertain (a refresh may have been processed without its result being stored; the person must sign in again), InvalidSignatureError and DuplicateEventError (webhooks), and ManagementError (HTTP errors from the Management API, with status, request_id, error and body).

Common problems:

  • Sign-in loops back to the login page on http://localhost. The default cookies are Secure and __Host- prefixed. See the note under Configuration for local development.
  • invalid_scope when starting sign-in. The scope is requested by the app but not allowed for the client, see Scopes.
  • Users are signed out when the app restarts or scales to several workers. MemoryStore is per process; use PostgresSessionStore.
  • 503 from every protected route. The app cannot reach stwrd; check STWRD_ISSUER and the network. Sessions are preserved meanwhile.

Compatibility

  • Python 3.12, 3.13 and 3.14.
  • FastAPI 0.115 or newer and Starlette 0.40 or newer (the fastapi extra).
  • httpx 0.27 or newer and joserfc 1.0 or newer.
  • PostgreSQL through psycopg 3.2 or newer (the postgres extra).
  • Type hints throughout; the package ships a py.typed marker.

Versioning

The package follows Semantic Versioning. Before 1.0, minor releases may contain breaking changes; they are listed in the changelog. Pin an exact or compatible-release version, for example stwrd-auth~=0.1.0.

License

MIT. Copyright (c) 2026 stwrd.

Metadata

Release files for stwrd-auth 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for stwrd-auth 0.1.0
File Size Uploaded Provenance
stwrd_auth-0.1.0.tar.gz 131.2 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for stwrd-auth 0.1.0
File Interpreter ABI Platform
stwrd_auth-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 208.0 kB

Release files / stwrd_auth-0.1.0.tar.gz

Download URL stwrd_auth-0.1.0.tar.gz
Size 131.2 kB
Tags Source
SHA-256 checksum
How to use checksums
53dcd7a7d030ed7c840c1348475e8bff4490beac199c7aa3b97f148e39fd2b51
BLAKE2b-256 checksum
How to use checksums
9a579972da287c2031e4541aebf87f2c8add559d3286b616f8e0577b967dbc85
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.23 {"installer":{"name":"uv","version":"0.12.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / stwrd_auth-0.1.0-py3-none-any.whl

Download URL stwrd_auth-0.1.0-py3-none-any.whl
Size 76.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7e51626d81eca69acf4bb439b952b459d017de0225600c84f47b7ad192276737
BLAKE2b-256 checksum
How to use checksums
f6eccb7f1603f17675614a0f8c9376c4351edc390ec3074d9f94b37529563c11
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.23 {"installer":{"name":"uv","version":"0.12.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Security for stwrd-auth 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page