A Seamless Auth server adapter for Python, with bindings for FastAPI and Django.
The adapter sits in your backend between your users and the Seamless Auth API:
- Browsers talk to it over
HttpOnlycookies on your own domain. The API's tokens never reach page scripts. - Native clients (mobile, CLIs) talk to it over bearer tokens, sending
x-seamless-auth-transport: bearer. - The auth API sees bearer tokens plus a service token that lets it trust the client address and user agent the adapter forwards.
Which routes the adapter serves, and what each does to the session, comes from the adapter manifest the auth API publishes, so a new API route works without a new release of this package.
For an API that only checks access tokens from the Seamless OpenID Provider, the package also has a verify-only mode that needs just the issuer and an audience.
It is held to the same conformance suite as the Express, Fastify, Go and Rust adapters, with a FastAPI and a Django reference app, in CI on every change.
pip install "seamless-auth[fastapi]" # or "seamless-auth[django]"Python 3.11 or later. Django 5.2 or later.
import os
from fastapi import Depends, FastAPI
from seamless_auth import Adapter, User
from seamless_auth.fastapi import RequireUser, auth_router
auth = Adapter(
auth_server_url=os.environ["AUTH_SERVER_URL"],
cookie_secret=os.environ["COOKIE_SECRET"], # at least 32 bytes
service_secret=os.environ["SEAMLESS_SERVICE_TOKEN"], # the API's API_SERVICE_TOKEN value
jwks_kid=os.environ["JWKS_KID"],
)
current_user = RequireUser(auth)
app = FastAPI()
# The auth routes, at /auth, which is where the client SDKs call.
app.include_router(auth_router(auth))
# Your own routes, behind the adapter's guard.
@app.get("/api/me")
def me(user: User = Depends(current_user)):
return {"id": user.id}The auth server reads the service token from API_SERVICE_TOKEN and your app reads it from
SEAMLESS_SERVICE_TOKEN; both hold the same value.
RequireUser answers 401 unless the request carries a session, and returns the User. It
answers 403 for a recovery session (see Recovery sessions) unless you pass
RequireUser(auth, allow_recovery=True).
# settings.py
SEAMLESS_AUTH = {
"auth_server_url": os.environ["AUTH_SERVER_URL"],
"cookie_secret": os.environ["COOKIE_SECRET"],
"service_secret": os.environ["SEAMLESS_SERVICE_TOKEN"],
"jwks_kid": os.environ["JWKS_KID"],
}
# urls.py
urlpatterns = [
path("auth/", include("seamless_auth.django")),
path("api/me", views.me),
]
# views.py
from django.http import JsonResponse
from seamless_auth.django import require_auth, user_of
@require_auth
def me(request):
return JsonResponse({"id": user_of(request).id})@require_auth(allow_recovery=True) admits a recovery session too (see
Recovery sessions).
SEAMLESS_AUTH takes the keyword arguments of Adapter (below). deliver and
resolve_client_ip may be dotted paths to callables. require_auth works on sync and async
views. The auth routes are exempt from Django's CSRF middleware: the adapter does its own
cross-site checks (below), and the client SDKs send no CSRF token.
The guard (RequireUser, require_auth) accepts the adapter's session cookie, or an auth API
access token in Authorization: Bearer for clients with no cookie jar. The cookie wins when both
are present. It does not refresh: the auth routes refresh a browser session silently, and a bearer
client calls POST /auth/refresh itself. Adapter.authenticate(headers) does the same check
anywhere else.
The session cookie is signed with cookie_secret, and the guard also verifies the auth API access
token inside it against the API's key set, as it does a bearer token: RS256, the expected issuer
and audience, in date, typ: access, and the same sub as the cookie. User.roles and
User.recovery come from that verified token, not from the cookie, so a forged cookie cannot carry
claims of its own even if the cookie secret leaks. The first request with a new key fetches the key
set, and when it cannot be fetched the guard answers 401.
A request the guard admits on the session cookie also gets the auth routes' cross-site check,
while cookies are SameSite=None and only for methods other than GET, HEAD and OPTIONS:
Sec-Fetch-Site: cross-siteanswers 403.Sec-Fetch-Site: same-sitealso covers a sibling subdomain, so whenallowed_originsis set the request must carry anOriginon that list, or it answers 403. Withoutallowed_originsit passes.Sec-Fetch-Site: same-origin(ornone) passes.- With no
Sec-Fetch-Site, anOrigin: nullanswers 403, and whenallowed_originsis set any otherOriginmust be on it. A request with neither header passes.
A bearer token is never attached by a browser, so it is not checked.
A user who signs in with a recovery code, or completes self-service account recovery, gets a
recovery session: its access token carries recovery: true, and the auth API refuses it on
everything but enrolling a new passkey or authenticator app until they do. The guard refuses it
too, with 403 { "error": "enrollment_required" } (FastAPI's RequireUser answers { "detail": "enrollment_required" }, as it does its other errors), so an application never treats a
half-recovered account as signed in. Allow it on the routes that guide the user through
enrollment with allow_recovery=True, and read User.recovery there.
Adapter.authenticate raises EnrollmentRequired, a subclass of Unauthenticated.
When a recovery session in cookie transport completes POST /webauthn/register/finish or
POST /totp/enroll/verify, the adapter refreshes the session in the same response, so the
access cookie drops the claim at once. If that refresh fails, the enrollment still answers as the
auth API did and the cookie keeps the claim until the next refresh. A bearer client holds its own
refresh token, so it calls POST /auth/refresh itself after enrolling.
The auth routes forward a request body to the auth API as JSON, so a body must be sent as
application/json (or a +json type). Anything else answers 415 unsupported_media_type: a
cross-site form can post a text/plain body shaped like JSON with no CORS preflight, and read as
JSON it would be a sign-in the user never made. The client SDKs already send JSON.
| Argument | Default | Purpose |
|---|---|---|
auth_server_url |
required | Where the adapter reaches the auth API |
auth_server_issuer |
the URL | Expected iss of the API's tokens, when it differs from the URL you reach it at |
audience |
the issuer | Expected aud of the API's tokens |
cookie_secret |
required | Signs the session cookies (32 bytes or more, and not a placeholder) |
service_secret |
required | The service token, read from SEAMLESS_SERVICE_TOKEN in the examples; the same value as the API's API_SERVICE_TOKEN (32 bytes or more, and not a placeholder) |
jwks_kid |
dev-main |
kid header on service tokens |
cookie_domain |
none | Cookie Domain |
insecure_cookies |
False |
Drops Secure, and allows placeholder secrets, for local development over HTTP |
same_site |
None, or Lax with insecure_cookies |
Cookie SameSite |
allowed_origins |
none | The only cross-origin callers allowed to change state while cookies are SameSite=None |
access_cookie_name, refresh_cookie_name |
seamless-access, seamless-refresh |
Session cookie names |
registration_cookie_name, pre_auth_cookie_name |
seamless-ephemeral |
Sign-in flow cookie names |
deliver |
none | Sends OTP codes and magic links through your own transports |
deliver_account_notices |
False |
deliver also sends account notices to the owner (below) |
resolve_client_ip |
the connecting peer | The end user's address. See below |
disable_manifest_fetch |
False |
Use only the manifest bundled with this version |
http_client |
15 second timeout, no redirects | Outbound httpx2.Client |
The adapter refuses to start with a known placeholder for either secret, such as the starters'
dev-only-cookie-signing-key-replace-me, GRAB_FROM_SEAMLESS_AUTH_PORTAL_DEV, or anything
containing replace-me, unless insecure_cookies is set.
The adapter forwards the end user's address and user agent so the auth API rate limits and audits against the user, not your server. Behind a proxy, name it:
from seamless_auth import TrustedProxies
auth = Adapter(..., resolve_client_ip=TrustedProxies(["10.0.0.0/8"]))TrustedProxies walks X-Forwarded-For from the right, skipping trusted proxies. There is
deliberately no hop-count option: a hop count cannot tell a proxy from a client that sent its
own header.
Without deliver, the auth API sends OTP codes and magic links itself. With it, the adapter
asks the API for the message and hands it to you:
from seamless_auth import Delivery
def deliver(d: Delivery) -> None:
mailer.send(d.to, d.kind, d.token, d.magic_link_url)
auth = Adapter(..., deliver=deliver)An async def callback works too; under FastAPI it runs on the application's event loop. A
delivery error answers the request with 502 delivery_failed. A delivery kind this version does
not know is logged and skipped.
An OTP code from POST /recovery/start carries purpose="account_recovery"
(Delivery.is_account_recovery): word that message as an account recovery code, with a warning
to ignore it if the user did not ask, not as a sign-in code.
When account recovery opens (POST /recovery/verify), the auth API tells the account's owner at
every verified address, with a link that cancels it. It sends those notices itself unless you
take them on as well:
auth = Adapter(..., deliver=deliver, deliver_account_notices=True)Then deliver also receives account_notice_email (to, subject, text, notice,
cancel_url) and account_notice_sms (to, text, notice, cancel_url), one call per
address. The auth API counts a notice it hands back as the owner being told, so turn this on only
when deliver sends both kinds. cancel_url (and text, which contains it) is a credential that
cancels the recovery: send it only to to, and never log it. Every notice is attempted. One that
gets through is enough, and the request answers as the auth API did. If none does, the owner has
not been told, so the adapter cancels the recovery request (POST /recovery/cancel with the token
from the cancel link) and answers 503 recovery_notice_failed, as the auth API itself would.
Without the option, routes that carry notices are called without external delivery, so the auth
API sends them through its own transports.
The auth API signs a user in from a magic link only on the device that asked for it. On
GET /magic-link/verify/{token} the adapter forwards that device's pre-auth token when it has
one: the client's own bearer token in bearer transport, or the pre-auth cookie in cookie
transport if it verifies. Without one it sends none, and never answers 401, because the API
falls back to the client address and user agent the adapter forwards.
Opened anywhere else, the link answers 409 confirmation_required, with requestedFrom
(deviceClass, requestedAt) describing the request, and is not consumed. Once the user
approves it, POST /magic-link/verify/{token}/confirm consumes it. The requesting attempt's
first GET /magic-link/check after that issues the session; later checks answer
{ "message": "Success" } with no tokens, and the adapter then leaves the cookies as they are.
The adapter can serve the Seamless admin dashboard from your API, proxied from the auth API, so it
shares the origin and cookie scope of the /auth routes:
# FastAPI
from seamless_auth.fastapi import console_router
app.include_router(console_router(auth)) # /console
# Django (urls.py)
from seamless_auth.django import console_urlpatterns
urlpatterns += [path("console/", include(console_urlpatterns))]It serves GET and HEAD only and forwards nothing but the method and path, so the browser's
cookies never reach the upstream. It refuses any path that could leave the console (dot segments,
encoded separators), and follows a redirect only while it stays inside the console on the auth API.
It keeps the auth API's X-Frame-Options, Content-Security-Policy and X-Content-Type-Options,
so the console cannot be framed on your origin. When you serve it, add your API's origin to the auth server's ORIGINS so passkey ceremonies
started in the console verify.
An API that only receives access tokens from the Seamless OpenID Provider (RFC 9068 JWTs, sent as
Authorization: Bearer) needs none of the adapter: no cookies, no service secret, no auth routes.
ResourceServer takes the provider's issuer and this API's resource identifier, and nothing else:
from seamless_auth import ResourceServer
rs = ResourceServer(
issuer="https://login.example.com", # the API's oidc_issuer
audience="https://api.example.com", # this API's resource identifier
)from fastapi import Depends, FastAPI
from seamless_auth import VerifiedUser, require_role, require_scope, require_step_up
from seamless_auth.fastapi import RequireBearer, add_bearer_error_handler
app = FastAPI()
add_bearer_error_handler(app)
read_orders = RequireBearer(rs, require_scope("orders:read"))
refund = RequireBearer(
rs, require_scope("orders:write"), require_step_up(acr_values=["phr"], max_age=300)
)
@app.get("/orders")
def orders(user: VerifiedUser = Depends(read_orders)):
return {"sub": user.sub, "org": user.org_id}
@app.post("/orders/{id}/refund")
def refund_order(id: str, user: VerifiedUser = Depends(refund)): ...RequireBearer(rs, *guards) verifies the token, runs the guards in order, and returns the
VerifiedUser. add_bearer_error_handler(app) answers refusals with the JSON bodies below; without
it FastAPI still sends the status and WWW-Authenticate, with the body under detail.
# settings.py
SEAMLESS_RESOURCE_SERVER = {
"issuer": os.environ["OIDC_ISSUER"],
"audience": os.environ["API_AUDIENCE"],
}
# views.py
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from seamless_auth.django import bearer_user_of, require_bearer, require_scope, require_step_up
@require_bearer
@require_scope("orders:read")
def orders(request):
return JsonResponse({"sub": bearer_user_of(request).sub})
@csrf_exempt # bearer only: no cookie for a cross-site request to ride on
@require_bearer
@require_scope("orders:write")
@require_step_up(acr_values=["phr"], max_age=300)
def refund(request, id): ...SEAMLESS_RESOURCE_SERVER takes the keyword arguments of ResourceServer; confirm may be a
dotted path. @require_bearer (or @require_bearer(server=rs)) verifies the token and puts the
VerifiedUser on the request; the guard decorators go beneath it and run top to bottom. A guard
with no @require_bearer above it answers as if there were no token. All of them work on sync and
async views.
A token passes only if all of these hold, and anything else answers 401 invalid_token:
- A compact JWS with header
alg: RS256,typofat+jwtorapplication/at+jwt(any case), akid, and nocrit. - Signed by the key with that
kidin the provider's key set, an RSA key whoseuse(if present) issigand whosealg(if present) isRS256. issequal toissuerexactly, andaudequal toaudienceor a list containing it.- In date:
exprequired,nbfoptional, with 5 seconds of clock skew. - A non-empty
subandclient_id, and no first-partytypclaim. - No
cnf(a sender-constrained token), unless aconfirmhook accepts it (below). scope,acr,sidstrings,amrandroleslists of strings,auth_timea number andorg_ida string or null, where present.
Keys come from jwks_uri when you pass one. Otherwise the first request fetches
<issuer>/.well-known/openid-configuration (5 second timeout, 256 KiB at most), which must name
the same issuer and an http(s) jwks_uri (https under an https issuer). Discovery succeeds once
per process; a failure answers 401 and is retried at most every 5 seconds. The key set is cached
as the adapter caches the auth API's: a new kid refetches it at most every 30 seconds, and it is
fetched again after 10 minutes.
VerifiedUser carries sub, client_id, scopes (the scope claim split on spaces), roles,
sid, org_id, acr, amr, auth_time, exp, iat, the raw token and every claims. A
client credentials token (sub == client_id, no user claims) is valid, and fails role and step-up
guards.
| Guard | Passes when | Otherwise |
|---|---|---|
require_scope(*scopes) |
every scope is in scopes, exactly |
403 {"error":"insufficient_scope","scope":"a b"}, WWW-Authenticate: Bearer error="insufficient_scope", scope="a b" |
require_role(*roles) |
any role is granted (x:* grants x and under, billing grants billing:..., p:write grants p:read) |
403 {"error":"insufficient_role"} |
require_step_up(acr_values=, amr=, max_age=) |
acr is one of acr_values (phr also accepts phrh), every amr is present, and at most max_age seconds since auth_time |
401 insufficient_user_authentication (RFC 9470) |
A missing token, a scheme other than Bearer or more than one token answers 401
{"error":"missing_token"} with WWW-Authenticate: Bearer. A step-up refusal names what the client
must ask the provider for, so it can send the user to sign in again:
WWW-Authenticate: Bearer error="insufficient_user_authentication",
error_description="More recent authentication is required", acr_values="phr", max_age="300"
with the same fields in the JSON body (max_age as a number). role_grants_access(granted, required) is the role matching on its own.
A token with a cnf claim is bound to a key the client must prove it holds (DPoP), so it is
refused unless you pass confirm. The hook gets the cnf value and a BearerRequest (method,
headers, absolute URL), and accepts the token by returning True. Anything else, including an
exception, refuses it:
def confirm(cnf, request):
return check_dpop_proof(cnf["jkt"], request.headers.get("DPoP"), request.method, request.url)
rs = ResourceServer(issuer=..., audience=..., confirm=confirm)The adapter is synchronous and thread-safe: one Adapter serves the whole process. Under FastAPI
the bindings run it on Starlette's threadpool, so it never blocks the event loop. Under Django it
runs in the request thread, on WSGI or ASGI. Refresh sharing (one rotation per refresh token,
handed to every parallel request for 5 seconds) works across threads.
On its first request the adapter fetches /.well-known/seamless-adapter.json from the auth API
(5 second timeout) and keeps it for the life of the process. If the API serves none, it uses the
copy bundled in this package and tries again a minute later. A manifest with anything this
version does not understand is refused whole, rather than following a route with the wrong token.
Refresh the bundled copy with scripts/sync-manifest.sh.
conformance/ holds the FastAPI and Django reference apps the conformance suite drives. To run
one locally you need Docker, a checkout of seamless-auth-api and the seamless CLI:
PORT=8080 AUTH_SERVER_URL=http://localhost:5312 AUTH_SERVER_ISSUER=http://auth-api:5312 \
SEAMLESS_SERVICE_TOKEN=verify-dev-service-token-not-a-real-secret \
COOKIE_SIGNING_KEY=verify-dev-service-token-not-a-real-secret JWKS_KID=dev-main \
uv run python conformance/fastapi_app.py & # or conformance/django_app.py
SEAMLESS_API_DIR=../seamless-auth-api seamless verify --adapter-url=http://localhost:8080Pre-1.0. The public API may change between minor versions until 1.0.
Apache-2.0
Project governance, including the Seamless Auth License Pledge, is in the fells-code GOVERNANCE.md.