fastapi-better-auth-bridge
A bridge to a TypeScript Better Auth server — not a Python port. (If you want a full Python re-implementation, this is not it.) Community-maintained; not affiliated with or endorsed by Better Auth.
Status. Three modes ship, and every change to any of them is conformance-tested in CI against a real Better Auth server rather than a mock: Mode B — a JWT verified against your server's JWKS — Mode A — the session cookie, verified against a shared session store — and Mode C — the same cookie, forwarded to your Better Auth server's own
get-sessionroute, fail-closed. Both cookie modes carry cross-site request forgery protection that is required rather than optional. No dates are promised.
Modes
Better Auth is TypeScript-only: sign-in/up, OAuth and 2FA run on your Node service. This package makes the sessions that service issues first-class in FastAPI. Three modes ship. They differ in what they couple to, what a request costs, and — the row that should decide it — what they can still see once a session goes away.
If your Better Auth server mounts
bearer(), setrequireSignature: truebefore anything else on this page. It defaults tofalse, and while it is false a raw session token written into a log line, a database dump or a backup is a liveAuthorization: Bearercredential. That is upstream's setting, so it holds in all three modes and no configuration here changes it. The one-line fix, and the startup gate Mode C can enforce it with: §requireSignature.
| A — cookie + shared store | B — JWT / JWKS | C — remote get-session | |
|---|---|---|---|
| Revocation lag | instant | ≤ token lifetime (15 min upstream default) | instant |
| Credential | the signed session cookie | Authorization: Bearer <JWT> |
the signed session cookie |
| Cost per request | one session-store read | none — verified offline | one HTTPS call upstream, unless the local pre-filter or the negative cache answers first |
| What it couples to | the shared secret, the same session store, and Better Auth's internal formats (cookie HMAC + store layout) | reachability of /api/auth/jwks, and nothing else |
network reach to your Better Auth server, its 200-with-null contract, and its {session, user} body shape — plus the signed-cookie envelope only if you configure a secret |
| CSRF | required, no default | not applicable — a bearer credential is not sent ambiently | required, no default |
| Expiry | refused here, because upstream's findSession does not check expiresAt |
refused: exp, and a ceiling on the exp - iat upstream would never have minted |
refused upstream — the get-session route checks expiresAt and answers null (dist/api/routes/session.mjs:148) — and re-checked here |
| Sign-out | the very next request is 401 |
invisible until the token expires | the very next request is 401 |
| Bans | refused here — upstream's get-session never reads banned either |
invisible until the token expires | refused here, from the record upstream returns |
| Algorithm confusion | not applicable | refused: a pinned algorithm allowlist, and an unknown kid is a refusal rather than a search |
not applicable |
| Secret rotation | a keyring, compared in constant time with no early return | upstream's own kid rollover |
the same keyring, when you configure one |
| Auth server down | keeps verifying — it never calls it | keeps verifying from the cached key set until that needs a refetch | refuses (401); guessing here would be the bypass |
Two caveats behind the "Bans" row. A ban is enforced by this library, from the user record it
already has, because upstream's get-session route never reads banned — the admin plugin enforces
bans when a session is created (dist/plugins/admin/admin.mjs:33-49) and deletes the user's
sessions when the ban goes through its own route (ban-user, dist/plugins/admin/routes.mjs:508,
sessions deleted at :540; update-user with banned: true does the same at :305). So a ban
written straight into the database is still caught here, in Mode A on SQL and in Mode C. It is
not caught when Better Auth runs with secondaryStorage and the ban is written straight to the
database: the session document in Redis was written before the ban and still says banned: false,
and that document is what both Mode A and Mode C read there. On that topology, ban through the
admin route — it deletes the sessions, and then every mode sees a sign-out.
Picking one. If FastAPI can share the database or Redis your Node service writes sessions to,
Mode A is the cheapest instant revocation. If it can only reach that service over the network, that
is Mode C. If it has to verify with no dependency on the auth server being reachable at all, that
is Mode B, and the price is a revocation lag equal to the token lifetime. Modes compose — each
request picks the verifier whose credential it actually carries, and two credentials on one request
are a 400 — with one exception: A and C read the same cookie name, so composing those two is
refused at construction. It is a choice between modes, not a stack.
Which better-auth and Python versions each lane exercises: COMPATIBILITY.md.
Install
Python 3.10+. The distribution is fastapi-better-auth-bridge — the shorter spelling collides with
an unrelated package under PyPI's name-similarity rules — and the import is fastapi_better_auth.
The two modes that talk to your Better Auth server — JwtVerifier for the key set, RemoteVerifier
for get-session — do it through a pluggable Transport: pick the adapter for the client your
project already has, or install neither extra and pass your own Transport.
| Extra | Installs | Adapter |
|---|---|---|
[httpx] |
httpx>=0.27 |
HttpxTransport, used by default when you pass no transport= |
[httpx2] |
httpx2>=2.0 |
Httpx2Transport |
The default client honours the process environment the way httpx does — HTTP_PROXY,
HTTPS_PROXY, NO_PROXY, SSL_CERT_FILE/SSL_CERT_DIR and ~/.netrc. That is usually what a
deployment behind an egress proxy or a private CA wants; if it is not, pass your own
HttpxTransport(client=httpx.AsyncClient(trust_env=False)). Either way the client never follows a
redirect and never keeps a cookie. SECURITY.md spells out what the library trusts.
pip install "fastapi-better-auth-bridge[httpx]" # or: uv add "fastapi-better-auth-bridge[httpx]"
Quickstart (Mode B)
Upstream prerequisite: the JWT plugin has to be
mounted on your Better Auth server (plugins: [jwt()]). It serves /api/auth/jwks, the key set
verified against, and /api/auth/token, where a signed-in client fetches its token; without it
there is nothing here to verify. Then, in FastAPI:
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi_better_auth import BetterAuth, JwtVerifier, Session, User
auth = BetterAuth(verifiers=[JwtVerifier(base_url="https://auth.example.com")])
CurrentSession = Annotated[Session[User], Depends(auth.current_session())]
app = FastAPI()
@app.get("/me")
async def me(session: CurrentSession) -> User:
return session.user
base_url is the whole of the trust configuration: canonicalized once, it is the required iss,
the required aud, and the origin the key set is fetched from — and nothing is derived from the
incoming request. If your deployment already sets BETTER_AUTH_URL for the Node side,
BetterAuth.from_env() reads exactly that one variable, raises if missing, and builds the same:
from fastapi_better_auth import BetterAuth
auth = BetterAuth.from_env()
Call the factory — Depends(auth.current_session()), with the parentheses. Passed bare it would
make the factory itself the dependency, a silent bypass of every route beneath a router, so every
Depends and Security planting of a bare factory is refused with a ConfigurationError while
the route is registered and the application never starts. The one exception is a bare factory
assigned into app.dependency_overrides, a plain dict this library has no hook into: there the
application does start, and the same refusal fires on the first request touching that dependency —
still verifying nothing, and still serving nobody.
/docs needs no wiring: the security scheme is derived from each verifier's own credential source,
so the Authorize button works out of the box.
Your own user model, and optional authentication
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi_better_auth import BetterAuth, JwtVerifier, Session, User
class Member(User):
role: str | None = None
auth = BetterAuth(verifiers=[JwtVerifier(base_url="https://auth.example.com")])
CurrentMember = Annotated[Session[Member], Depends(auth.current_session(user_model=Member))]
MaybeMember = Annotated[Session[Member] | None, Depends(auth.optional_session(user_model=Member))]
app = FastAPI()
@app.get("/role")
async def role(session: CurrentMember) -> str:
return session.user.role or "member"
@app.get("/greeting")
async def greeting(session: MaybeMember) -> str:
return "hello" if session is None else f"hello, {session.user.id}"
optional_session returns None for one situation only: no credential was presented at all. One
that was presented and did not verify still fails — a forged or expired token is never downgraded
to "anonymous".
Fields the admin plugin adds: AdminUser
If your Better Auth server mounts the admin plugin it
adds four columns to user — role, banned, banReason, banExpires — and one to session,
impersonatedBy (dist/plugins/admin/schema.mjs:3-30). AdminUser is the subclass you would otherwise
write for the four:
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi_better_auth import AdminUser, BetterAuth, JwtVerifier, Session
auth = BetterAuth(verifiers=[JwtVerifier(base_url="https://auth.example.com")])
CurrentAdmin = Annotated[Session[AdminUser], Depends(auth.current_session(user_model=AdminUser))]
app = FastAPI()
@app.get("/role")
async def whoami(session: CurrentAdmin) -> str:
return session.user.role or "unknown"
The plugin declares all five input: false (same file), so they are server-controlled and an account
holder can never set one at sign-up — which is what makes them worth typing, unlike an additionalFields
entry. Without the plugin the keys are simply absent and every field reads None, which means
unknown and never safe: a missing banned is not an unbanned user.
banned is a view, not a decision. In Modes A and C the ban is enforced by the verifier — from
the store record or the upstream document — before the model is built, so a live ban is a 401 and
your route never runs. A banned=True you can actually read is therefore a ban that has lapsed
(ban_expires in the past), or Mode B data, where a JWT carries whatever was true when it was
minted. It is also a StrictBool: a 1 or a "true" on the wire is refused rather than guessed at.
The session half sits on the session, not the user. session.impersonated_by is the admin's user id
when this session came from the plugin's impersonation endpoint, and None otherwise — including in
Mode B, always, because a JWT carries the user object and no session row. It is provenance, not
permission: it tells you an administrator is acting as this user, never that the request may do
anything extra.
additionalFields: make sure they reach the wire
A User subclass ignores keys it does not declare, and generates a camelCase alias from each Python
name. That is the right default — upstream ships often, and a field you have not declared must not
turn an authentic request into a 500 — but it has one sharp edge worth knowing before you rely on
it: a name mismatch between your Node-side additionalFields key and your Python field is not an
error. Declare jurisdiction_scope (wire key jurisdictionScope) while the server sends
jurisdiction, and every request reads None for ever.
Which way that fails is your choice, and it is the annotation that makes it:
from fastapi_better_auth import User
class Lenient(User):
"""Forward-compatible. A name mismatch reads None, and nothing says so."""
jurisdiction_scope: str | None = None
class Scoped(User):
"""Fails closed. A name mismatch refuses every request, from the first one."""
jurisdiction_scope: str
A required field is the strict mode; there is no separate switch. The refusal is the same uniform
401 as everything else, so the client learns nothing — but the diagnosis is on the exception as
InvalidCredential.reason, naming the wire key the model expected:
Scoped payload rejected (1): jurisdictionScope: [missing]
and the first time it happens the library logs one WARNING per process per user model on the
fastapi_better_auth logger, naming the model and the missing wire keys. Nothing the payload
carried appears in either — only the field names your own model declared.
Neither shape can tell you the names are right before the first request, so verify them once, as a smoke test against your real Better Auth server. The check is the same in every mode: sign in, then assert the key is on the wire where that mode reads it.
# Run against a real Better Auth server, once per deployment, in your own test suite.
EXPECTED = {"jurisdictionScope", "role"} # the WIRE keys, camelCase
# Mode B — the JWT payload. Fetch a token and decode it WITHOUT verifying; you are
# inspecting shape, not authenticating.
# POST /api/auth/sign-in/email -> cookie
# GET /api/auth/token -> {"token": "..."}
# claims = jwt.decode(token, options={"verify_signature": False})
# assert EXPECTED <= claims.keys()
#
# Mode C — the get-session body.
# GET /api/auth/get-session with the cookie -> {"session": {...}, "user": {...}}
# assert EXPECTED <= body["user"].keys()
#
# Mode A — the store record, read through the store you configured.
# record = await store.fetch_session_by_token(raw_token)
# user = record.user or await store.fetch_user_by_id(record.user_id)
# assert EXPECTED <= user.payload.keys()
There is deliberately no startup gate for this. At boot there is no payload to inspect in any mode —
Mode B has no token, Mode C's readiness probe carries no cookie, and Mode A's store has no
particular user — so a boot check would have to sign in with a real credential, which is exactly the
smoke test above, and the smoke test belongs in your suite rather than in your lifespan.
Quickstart (Mode A — session cookie)
The mode to reach for when the browser talks to FastAPI directly, carrying the cookie Better Auth set on sign-in. It verifies that cookie's signature against your shared secret, looks the session up in the store Better Auth writes to, and enforces expiry and revocation the moment the store says the session is gone — the instant revocation a JWT cannot give you.
A cookie-authenticated mode has to answer cross-site request forgery, so csrf= is required and
has no default. OriginCheck is what most deployments want; SignedDoubleSubmit and —
deliberately, spelled out in your own source — CsrfDisabled(reason="…") are the alternatives.
Bring your own store. Any object with these two async reads is a SessionStore, and the shipped
adapters get no privilege yours does not. A dict-backed one is enough to run:
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi_better_auth import (
BetterAuth,
CookieVerifier,
OriginCheck,
Session,
SessionStore,
SharedSecret,
StoredSession,
StoredUser,
User,
)
class DictSessionStore:
"""A SessionStore backed by two dicts: the whole Protocol, and nothing more.
A real deployment reads the rows or keys Better Auth already writes (see below); this is
the shape of the two questions a store answers — a read, never a write.
"""
def __init__(self, sessions: dict[str, StoredSession], users: dict[str, StoredUser]) -> None:
self._sessions = sessions
self._users = users
async def fetch_session_by_token(self, token: str) -> StoredSession | None:
return self._sessions.get(token)
async def fetch_user_by_id(self, user_id: str) -> StoredUser | None:
return self._users.get(user_id)
store: SessionStore = DictSessionStore(sessions={}, users={})
auth = BetterAuth(
verifiers=[
CookieVerifier(
# A literal only so this page runs; in production read it from the environment —
# SharedSecret(os.environ["BETTER_AUTH_SECRET"]) — the value the Node side signs with.
secret=SharedSecret("replace-this-with-your-own-32-plus-character-secret"),
store=store,
csrf=OriginCheck(allowed_origins=["https://app.example.com"]),
)
]
)
CurrentSession = Annotated[Session[User], Depends(auth.current_session())]
MaybeSession = Annotated[Session[User] | None, Depends(auth.optional_session())]
# withCredentials lets the /docs "Try it out" button send the browser's own cookie to a same-site API.
app = FastAPI(swagger_ui_parameters={"withCredentials": True})
@app.post("/posts")
async def create_post(session: CurrentSession) -> dict[str, str]:
return {"author": session.user.id}
@app.post("/reactions")
async def add_reaction(session: MaybeSession) -> dict[str, str | None]:
return {"author": None if session is None else session.user.id}
The protected routes are POSTs on purpose: cross-site request forgery is a threat to unsafe
methods, so a cookie-authenticated write is exactly where OriginCheck earns its place. A forged or
absent cookie is a terminal 401; a cross-site write carrying a real cookie is a 403, decided
before the signature is even checked.
In production, point the store at the rows Better Auth already writes. That needs an extra —
fastapi-better-auth-bridge[sqlalchemy] or [redis] — and a live backend, so it is shown here
rather than executed on this page:
import os
from sqlalchemy.ext.asyncio import create_async_engine
from fastapi_better_auth import (
BetterAuth, CookieVerifier, OriginCheck, SharedSecret, SqlAlchemySessionStore,
)
engine = create_async_engine(os.environ["DATABASE_URL"]) # the database Better Auth writes sessions to
store = SqlAlchemySessionStore(engine=engine)
auth = BetterAuth(
verifiers=[
CookieVerifier(
secret=SharedSecret(os.environ["BETTER_AUTH_SECRET"]),
store=store,
csrf=OriginCheck(allowed_origins=["https://app.example.com"]),
)
]
)
# Where other services also own the `user` table, name the extra columns that may travel:
# store = SqlAlchemySessionStore(engine=engine, user_columns=["tenantId"])
# Redis secondary-storage instead of SQL:
# from fastapi_better_auth import RedisSessionStore
# store = RedisSessionStore(url=os.environ["REDIS_URL"]) # a miss is a 401, never a DB fall-back
SqlAlchemySessionStore reads the session and user tables directly; RedisSessionStore reads
the raw-token key Better Auth's secondaryStorage writes. A store reads; it never writes — no
touch, no EXPIRE-on-read — because a write here would extend or resurrect a session this side was
only asked to verify.
Every column of both tables is read by default, which is what makes your own additionalFields
reach session.user — so do not put a secret on either table. Where the user table is shared with
services that add internal columns to it, user_columns= (and session_columns= for the other
table) names the extra columns that may be selected, and nothing else is. Both are opt-in and change
no default, and neither narrows Better Auth's own set: the required columns are how a session is
found at all, and banned / banExpires / impersonatedBy stay selected whatever the list says,
because a ban is this library's business to enforce. A name the live table does not have is a
ConfigurationError at connect(), beside the missing-column one. RedisSessionStore has no
equivalent — it reads one stored JSON document, and a document has no SELECT to narrow.
Is it the same secret? Fingerprint both sides at boot
A secret with the right shape and the wrong value is accepted at construction and then fails one
request at a time, as a constant-time comparison miss inside the verifier — the same uniform 401 a
forged cookie earns, and by default not logged at all. Two deployment configs, two secret stores, one
stale copy, and the symptom is "nobody can sign in" with nothing anywhere saying why.
SharedSecret.fingerprint closes that, and it costs one line on each side. It is tok_fp= followed
by the first eight hex characters of SHA-256 over the secret's UTF-8 bytes: stable for a given value,
safe to log, and not reversible into the secret.
import contextlib
import logging
from collections.abc import AsyncIterator
from fastapi import FastAPI
from fastapi_better_auth import VERIFIED_BETTER_AUTH, SharedSecret
logger = logging.getLogger("myapp")
# A literal only so this page runs — in production, SharedSecret(os.environ["BETTER_AUTH_SECRET"]).
secret = SharedSecret("replace-this-with-your-own-32-plus-character-secret")
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
logger.info("better-auth secret %s", secret.fingerprint)
logger.info("better-auth verified against %s", VERIFIED_BETTER_AUTH)
yield
app = FastAPI(lifespan=lifespan)
Hand that same secret object to your CookieVerifier (or RemoteVerifier): one value, one
fingerprint, nothing to keep in step. Then print the matching string from your Better Auth server's
own startup — the identical construction, so the two are comparable by eye:
import { createHash } from "node:crypto";
console.log(
"better-auth secret tok_fp=" +
createHash("sha256").update(process.env.BETTER_AUTH_SECRET, "utf8").digest("hex").slice(0, 8),
);
The two lines are written to match, prefix and all, so when the secrets match the boot logs read
identically. Two different strings is the whole check — and you have it at deploy time rather
than in a support ticket an hour later. That the two constructions really do agree, on ASCII and on
non-ASCII secrets alike, is pinned by tests/test_shared_secret.py; neither line gives anything
back but the label.
One more line worth logging there. VERIFIED_BETTER_AUTH is the tuple of Better Auth versions
this release's conformance lane is actually run against — log it beside the Better Auth version your
own deployment pins, and an upstream bump this library has not been driven against shows up in a boot
log and in code review instead of in an incident. It is advisory and can only be: Mode A reads a
database or a Redis key and never the server, so nothing here can ask what is really running.
What Mode A refuses, and when
| Check | Behaviour |
|---|---|
| Revocation | Instant. Sign-out deletes the session from the store; the very next request reads the miss and is refused (401). |
| Expiry | Enforced by the verifier. Upstream's findSession() does not check expiresAt, so a bare DB join honours an expired session forever; this verifier refuses it. |
| Signature | token + "." + base64(HMAC-SHA256(secret, token)), standard base64, split at the last dot, compared in constant time against a keyring — exact wire parity with better-call. |
| CSRF | Required, and checked before the signature, so a cross-site 403 is never an oracle for whether the cookie behind it is currently valid. |
A Redis-authoritative deployment is a hard rule: a store miss is a 401, never a fall-back to a
database. When Better Auth runs with secondaryStorage, sign-out deletes the Redis key while a stale
row can still sit in Postgres, so a fall-back would resurrect exactly the sessions a sign-out
revoked.
/docs
The cookie route publishes an APIKeyCookie scheme, so /docs shows an Authorize field for it and
the security requirement appears on the operation. Two honest limits:
- Swagger UI cannot set a cookie from the Authorize modal
(swagger-api/swagger-ui#9710); the field
is declarative. What makes "Try it out" work is
FastAPI(swagger_ui_parameters={"withCredentials": True}), which tells Swagger to send the browser's own cookie — the one Better Auth already set — so a developer signed in on a same-site/docscan exercise the route. - The scheme is documentation only. What it would read is never read; every credential comes from the verifier that owns it.
Deploying across two origins
The common shape is a front end on app.example.com and this API on api.example.com. Three things
have to line up before one request works, and only the last of them is this library's:
- Better Auth sets the session cookie
SameSite=None; Secure, in its__Secure-prefixed form — whichCookieVerifierreads by default. Upstream's own default issameSite: "lax"(better-auth@1.7.1dist/cookies/index.mjs:35), overridden byadvanced.defaultCookieAttributes, which is spread over those defaults at:39. WithoutSameSite=Nonethe browser never attaches the cookie to a cross-origin request at all, and there is nothing on this side to verify. - FastAPI answers with CORS credentials allowed, from an explicit origin list. A wildcard is
not an option here: a browser refuses a credentialed response whose
Access-Control-Allow-Originis*, so name the front end you actually serve. - The cookie mode's CSRF policy allows that same origin.
SameSite=Noneis exactly where CSRF stops being optional, which is whycsrf=has no default.
Points 2 and 3 are both on this side, and both are in this application:
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi_better_auth import (
BetterAuth,
CookieVerifier,
OriginCheck,
Session,
SessionStore,
SharedSecret,
StoredSession,
StoredUser,
User,
)
FRONT_END = "https://app.example.com"
class DictSessionStore:
"""The two reads a `SessionStore` is. Empty here; in production, the real store above."""
def __init__(self) -> None:
self.sessions: dict[str, StoredSession] = {}
self.users: dict[str, StoredUser] = {}
async def fetch_session_by_token(self, token: str) -> StoredSession | None:
return self.sessions.get(token)
async def fetch_user_by_id(self, user_id: str) -> StoredUser | None:
return self.users.get(user_id)
store: SessionStore = DictSessionStore()
auth = BetterAuth(
verifiers=[
CookieVerifier(
# A literal only so this page runs — in production,
# SharedSecret(os.environ["BETTER_AUTH_SECRET"]).
secret=SharedSecret("replace-this-with-your-own-32-plus-character-secret"),
store=store,
csrf=OriginCheck(allowed_origins=[FRONT_END]),
)
]
)
CurrentSession = Annotated[Session[User], Depends(auth.current_session())]
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=[FRONT_END],
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["content-type"],
)
@app.post("/comments")
async def comment(session: CurrentSession) -> dict[str, str]:
return {"author": session.user.id}
The browser has to opt in as well, once, wherever your front end calls this API:
fetch("https://api.example.com/comments", {method: "POST", credentials: "include"}). Without
credentials: "include" no cookie is attached and every request arrives anonymous.
CORS is not the CSRF control, and the two allowlists are not the same control over one list. A
cross-site form POST is not preflighted: it reaches your route whatever allow_origins says,
carrying the SameSite=None cookie with it, and CORS withholds only the response from the
attacker's page. What refuses the request itself is the CSRF policy. Keep the two lists in step —
and never treat either as standing in for the other.
OriginCheck is the floor; but a bare double-submit cookie proves only that the sender could set
a cookie, and a sibling subdomain can set one on the shared parent domain — so on a shared parent
domain reach for SignedDoubleSubmit, whose token is HMAC(secret, session_token), bound to the
session and useless to a sibling. A non-browser client (mobile, server-to-server) has no
Origin for OriginCheck to trust and belongs on Mode B (bearer) instead: compose both
verifiers and each request picks its own by which credential it carries. All of this applies
unchanged to Mode C, which reads the same cookie.
More than one front end is expected, not exceptional. allowed_origins is a sequence and
nothing says it holds one entry —
OriginCheck(allowed_origins=["https://app.example.com", "https://admin.example.com"]) is a
two-front-end deployment, and SignedDoubleSubmit takes the same argument. Every entry must be an
origin a page is genuinely served from, including this API's own origin when a page served from
here posts back to it: a same-origin POST still carries Origin, and an allowlist that omits it
refuses every one of those requests. An empty list, a bare string, and two spellings of one origin
are each refused at construction rather than at 3 a.m.
The other route is upstream: crossSubDomainCookies. Better Auth has a switch that makes the
session cookie same-site for both hosts instead of cross-site:
advanced.crossSubDomainCookies, whose shape is enabled, additionalCookies and
domain (@better-auth/core@1.7.1 dist/types/init-options.d.mts:314-330). With it on, the cookie
carries a Domain= attribute (better-auth@1.7.1 dist/cookies/index.mjs:38), and a cookie scoped
to example.com is sent to app.example.com and api.example.com alike.
Read what the code does with domain, not what the option's doc comment says it does. The
comment says "By default, the domain will be the root domain from the base URL"
(init-options.d.mts:326-327). The code performs no shortening of any kind: Domain= becomes the
domain you configured, or else the hostname of your baseURL —
options.advanced?.crossSubDomainCookies?.domain || (baseURLString ? new URL(baseURLString).hostname : void 0)
(better-auth@1.7.1 dist/cookies/index.mjs:24-25). So a baseURL of
https://api.example.com with enabled: true and no domain yields Domain=api.example.com — a
cookie that still never reaches the sibling it was turned on for. If you enable it, set domain
explicitly.
Reach for it when the two hosts are siblings under a domain you own and one same-site cookie is
simpler than a cross-site one. Stay on the plain SameSite=None path above when they are not
siblings at all (example.com and example-api.net), when either host sits on a platform domain
you do not control (below), or when you would rather not widen the cookie's scope: Domain=example.com
sends it to every subdomain that exists today and every one that ever will, which is a larger blast
radius than the pair of origins you meant to connect.
A platform domain cannot do this. A
Domain=attribute may not name a public suffix. RFC 6265 §5.3 step 5 is exact about it: a user agent configured to reject public suffixes ignores the cookie entirely, unless the attribute is identical to the request host, in which case the cookie is narrowed to that one host. Either way it never reaches a sibling. The Public Suffix List (publicsuffix.org, fetched 2026-09-09, 16 477 lines) containsfly.dev(line 13647),herokuapp.com(14020),up.railway.app(15425),onrender.com(15457) andvercel.app(16245). Soyourapp-web.up.railway.appandyourapp-api.up.railway.appcannot share a cookie, and neither can two*.vercel.appdeployments — the browser drops theSet-Cookieand nothing in either log says why. No configuration on either side changes it. The fallback that does work there is the plain cross-origin path above:SameSite=None; Secure, CORS with credentials, andOriginCheck. Put a domain you own in front of both hosts andcrossSubDomainCookiesis back on the table.
Quickstart (Mode C — remote get-session)
The mode to reach for when FastAPI can reach your Better Auth server over the network but does
not share its database, its Redis, or — unless you choose to — its secret. It takes the session
cookie off the request, forwards exactly that one cookie to
GET /api/auth/get-session?disableCookieCache=true&disableRefresh=true, and believes the answer.
disableCookieCache switches off the cookie-cache short-circuit so the route always falls through
to the authoritative lookup (dist/api/routes/session.mjs:48) — that is where the instant
revocation comes from — and disableRefresh keeps the call read-only. Everything that could refuse
the request without asking upstream — the structural check, your optional secret, the negative
cache, the backoff latch — runs first.
Like Mode A this is a cookie mode, so csrf= is required and has no default, and the protected
routes below are POSTs for the same reason: cross-site request forgery is a threat to unsafe
methods.
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi_better_auth import (
BetterAuth,
OriginCheck,
RemoteVerifier,
Session,
SharedSecret,
User,
)
auth = BetterAuth(
verifiers=[
RemoteVerifier(
base_url="https://auth.example.com",
csrf=OriginCheck(allowed_origins=["https://app.example.com"]),
# Optional, and worth it: with a secret configured, a forged cookie is refused
# locally, before any upstream call. A literal only so this page runs — in
# production, SharedSecret(os.environ["BETTER_AUTH_SECRET"]).
secret=SharedSecret("replace-this-with-your-own-32-plus-character-secret"),
)
]
)
CurrentSession = Annotated[Session[User], Depends(auth.current_session())]
MaybeSession = Annotated[Session[User] | None, Depends(auth.optional_session())]
# withCredentials lets the /docs "Try it out" button send the browser's own cookie to a same-site API.
app = FastAPI(swagger_ui_parameters={"withCredentials": True})
@app.post("/articles")
async def publish(session: CurrentSession) -> dict[str, str]:
return {"author": session.user.id}
@app.post("/claps")
async def clap(session: MaybeSession) -> dict[str, str | None]:
return {"reader": None if session is None else session.user.id}
That snippet is executed by this repository's test suite on every commit, with the network refused
outright — which is exactly the claim it is there to prove. Constructing a RemoteVerifier opens no
connection, and because a secret is configured, every forged cookie those two routes are driven with
is refused here, with zero upstream calls. Take the secret out and the same forgeries become one
get-session call each.
Wire the startup probe in production. RemoteVerifier has one piece of boot work, and the
snippet above leaves it out only so the page can run offline:
import os
from fastapi import FastAPI
from fastapi_better_auth import BetterAuth, OriginCheck, RemoteVerifier, SharedSecret
auth = BetterAuth(
verifiers=[
RemoteVerifier(
base_url=os.environ["BETTER_AUTH_URL"],
csrf=OriginCheck(allowed_origins=["https://app.example.com"]),
secret=SharedSecret(os.environ["BETTER_AUTH_SECRET"]),
# Refuse to start against a server whose bearer plugin is at requireSignature: false.
refuse_unsigned_bearer=True,
)
]
)
# The probe runs once here; a server that fails it never starts serving.
app = FastAPI(lifespan=auth.lifespan)
FastAPI(lifespan=auth.lifespan) runs BetterAuth.startup(), which runs each verifier's prepare()
once — for this one, the probe. The probe is a single bare get-session request carrying no cookie,
and it asserts the contract Mode C rests on: reachable, 200, application/json, body exactly
null. A base_path that points at nothing is a ConfigurationError naming the URI it tried, at
boot, rather than a 401 per request forever. It is also the backstop against a Transport that
retains cookies: both shipped adapters install a dead cookie jar, but a Transport you write is
yours, and a session document coming back from a request that carried no cookie means the client is
replaying somebody's. That is refused by name rather than served. refuse_unsigned_bearer=True
adds one more rung to the same probe — the upstream bearer plugin's posture, refused at boot
instead of warned about; see § requireSignature.
What the probe does not prove: that a real cookie will verify, that your secret matches, that the server will still be up on the next request. It proves the URI and the contract, once.
Without the lifespan, the probe still runs — lazily, on the first request that gets that far. A
contract failure there is remembered permanently, and every later request raises the same
ConfigurationError. A reachability failure is not remembered: the request is refused with a 401
and the probe is retried at most once every ten seconds, so an auth service that comes up a moment
after yours recovers on its own.
What one request costs. The order below is the design, and everything above the fetch makes zero outbound calls by construction:
- The cookie is resolved by name, and a structurally impossible one is refused — too long, not decodable, no signature separator.
- The CSRF policy runs, before anything else looks at the credential, so a cross-site
403is never an oracle for whether the cookie behind it is live. - If you configured a secret, the signature is verified locally against the keyring. A forgery stops here and never becomes traffic.
- A
200-with-nullverdict is remembered for 30 seconds by default, keyed on the whole cookie value, so a forgery flood costs one upstream call per window rather than one per request. - If upstream has recently answered
429, the backoff latch refuses without calling it again. - Only then the call, through a concurrency limiter (8 in flight by default) so one stalled auth service cannot park every worker task.
Then the answer is checked rather than trusted: the returned session.token must match the token
that was forwarded, expiry is enforced here, and bans are enforced here. Anything else — a non-200,
a timeout, a body that is not a usable session document — is a refusal, never a pass.
The shared rate-limit bucket
Read this before Mode C sees real traffic. Better Auth rate-limits its own routes, and the way it
keys the bucket means every user of your FastAPI service shares one bucket for /get-session,
because they all reach your Node service from one address.
The mechanism, read out of better-auth 1.7.1's own build:
- The limiter is on when you have not set
rateLimit.enabledandNODE_ENV === "production"(dist/context/create-context.mjs:171,@better-auth/core/dist/env/env-impl.mjs:30-32) — off in development, on in production, which is the worst order in which to discover it. - The default bucket is 100 requests per 10 seconds (
dist/context/create-context.mjs:172-173), and there is no built-in rule for/get-session(dist/api/rate-limiter/index.mjs:302-316), so the default is what applies to it. - The key is
`${ip}|${path}`(@better-auth/core/dist/utils/ip.mjs:226-228, built atdist/api/rate-limiter/index.mjs:245). - The only client-IP header read by default is
x-forwarded-for(@better-auth/core/dist/utils/ip.mjs:194, walked at:205), and when no address can be derived the key falls back to the shared sentinelno-trusted-ip(dist/api/rate-limiter/index.mjs:233, used at:245). A server-to-server call forwards no such header, so that sentinel is the bucket your whole deployment lands in — one bucket per path, for everybody.
In round numbers: about ten verified requests per second across the entire deployment, for every request that misses this library's pre-filter and negative cache. Nothing in this library raises that ceiling. The pre-filter, the cache, the concurrency limiter and the 429 latch all reduce how often you reach the bucket; none of them makes it bigger. The fix is upstream configuration.
Fix 1 — exempt the route. The whole rule value is false. There is no max: false field and no
IP allowlist:
rateLimit: { customRules: { "/get-session": false } }
(dist/api/rate-limiter/index.mjs:259-276; if (resolved === false) return null at :274.) Know
what that buys: it removes the limit for every caller of /get-session on your Node server — a
browser hitting the route directly included — not only this bridge's server-to-server traffic. If
/get-session should keep a ceiling, use Fix 2.
Fix 2 — or raise it for that one route, if you would rather keep a ceiling:
rateLimit: { customRules: { "/get-session": { window: 10, max: 1000 } } }
Fix 3 — or run Mode A or Mode B, which make no upstream call per request at all.
When the bucket does refuse, upstream answers 429 with X-Retry-After — not Retry-After
(dist/api/rate-limiter/index.mjs:64-69), which is worth knowing before you go hunting for the
standard header in your own logs. Its value is the whole seconds left in the window, measured from
the last request upstream allowed: a refused request does not move the bucket
(dist/api/rate-limiter/index.mjs:47-52, and the memory backend writes only on an allowed decision
at :217). This library reads Retry-After and then X-Retry-After, clamps to 1–60 seconds, and
latches: while the latch holds every request is refused with zero outbound calls, one warning is
logged, and it clears by time alone. The latch is per verifier instance, so eight worker processes
have eight of them.
requireSignature: a raw session token is a bearer credential
If your Better Auth server mounts the bearer plugin, check this option before anything else on
this page. requireSignature defaults to false, and while it is false a raw, unsigned session
token presented as Authorization: Bearer <token> is signed by the server with its own secret and
installed as the session cookie (dist/plugins/bearer/index.mjs:34-38, then :46). It
authenticates. Which means a session token in a log line, a database dump, a backup or an error
report is not an identifier — it is a credential.
The fix is one line upstream:
bearer({ requireSignature: true })
With it set, a dot-less token is ignored outright (dist/plugins/bearer/index.mjs:36). That is not
advice taken on faith: the conformance lane runs two live Better Auth servers, one at each setting,
and pins the behaviour in both directions — tests/e2e/test_conformance.py::TestBearerPosture.
RemoteVerifier checks for the permissive posture at startup. Alongside the probe it sends one
request carrying a manufactured random token and looks at nothing but whether a set-cookie header
came back: the permissive posture emits one, the strict posture does not. By default that check is
advisory — one warning per process naming the fix, and nothing else. It never reads that header's
value and never replays a real credential.
Make it a hard gate with RemoteVerifier(refuse_unsigned_bearer=True). The same request becomes
a rung of the probe: a set-cookie is a ConfigurationError naming
bearer({ requireSignature: true }), so prepare() refuses and a server wired through
FastAPI(lifespan=auth.lifespan) never starts. It is remembered like every other contract failure —
a deployment that skipped the lifespan and probes lazily refuses every request instead. Its own
reachability failure is not a verdict: that stays the transient AuthServiceUnavailable the
unreachable-at-boot path already handles, and is retried rather than remembered. The flag is
opt-in because the posture is your server's to set, and off is the current behaviour exactly.
Modes A and B have no such check, and cannot. Neither talks to your Better Auth server — Mode A reads the session store, Mode B verifies offline against a cached key set — so neither is ever in a position to observe the plugin's posture. There the warning above is documentation, and the fix is still the same one line upstream.
Sessions do not slide on bridge traffic
Every get-session call this library makes pins disableRefresh=true, so the route takes its
read-only branch (dist/api/routes/session.mjs:163-170) instead of the one that calls
updateSession and pushes expiresAt forward (:191-195). That is deliberate — a verifier that
extended sessions would make your API's request rate a factor in how long people stay signed in, and
would be handed a Set-Cookie the browser never sees — but the consequence deserves stating plainly:
traffic to FastAPI does not keep a session alive.
A session expires session.expiresIn after it was issued (7 days by default,
dist/context/create-context.mjs:147), however busy your API is. Better Auth normally slides that
forward once a session is older than session.updateAge (1 day by default, :146) — but
only requests that reach Better Auth do it.
Two remedies, and most deployments already have the first:
- The browser also talks to the Node server — signing in, refreshing, any Better Auth route at all. Sessions then slide there exactly as they always did and nothing here changes.
- Otherwise raise
expiresInupstream to whatever "signed in" should mean for your product, and accept that it is a fixed window from sign-in rather than a sliding one.
Mode A behaves the same way, for the same reason: a store read is a read, never a write.
Organizations and roles — the recipe
Two helpers, both built on a session that is already verified, and both refusing with the same
uniform 403 this library uses everywhere else. require(predicate, ...) gates a route on a rule
about the user; require_membership(id_param, member, ...) gates it on this request's resource.
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi_better_auth import BetterAuth, JwtVerifier, Session, User
auth = BetterAuth(verifiers=[JwtVerifier(base_url="https://auth.example.com")])
class Member(User):
role: str | None = None
def is_editor(session: Session[Member]) -> bool:
return session.user.role in {"editor", "owner"}
Editor = Annotated[
Session[Member],
Depends(auth.require(is_editor, reason="editor role", user_model=Member)),
]
app = FastAPI()
@app.get("/drafts")
async def drafts(session: Editor) -> list[str]:
return [session.user.id]
Build the dependency once, at module level, exactly like the Annotated aliases above it: each
call makes a new one, and a new one per request would defeat FastAPI's per-request cache. Composing
on current_session is what makes a route that declares both the session and the gate verify
exactly once.
The predicate is synchronous and only True passes — compared by identity, so a truthy accident
(a database row, a non-empty error string, the coroutine an async def predicate returns) refuses
rather than admits. An async def predicate is a ConfigurationError rather than a permanent,
silent 403. Whatever the predicate raises is logged and answered 403, except a SessionError or
ConfigurationError it raised on purpose, which keeps its own shape.
The organization id comes from the request — its path or its query — and membership is checked with
your own query. Never from session.raw["activeOrganizationId"].
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi_better_auth import BetterAuth, JwtVerifier, Membership, Session, User
auth = BetterAuth(verifiers=[JwtVerifier(base_url="https://auth.example.com")])
ROLES = {("org_a", "user_1"): "owner"}
async def member_of(org_id: str, session: Session[User]) -> str | None:
"""Your query. The role, or None for "not a member of THIS organization"."""
return ROLES.get((org_id, session.user.id))
OrgMember = Annotated[
Membership[User, str],
Depends(auth.require_membership("org_id", member_of, reason="organization member")),
]
app = FastAPI()
@app.get("/orgs/{org_id}/invoices")
async def invoices(access: OrgMember) -> dict[str, str]:
return {"org": access.resource_id, "role": access.grant}
"org_id" becomes a required str parameter of the dependency, so FastAPI binds it from the
path when the route declares one by that name — and from the query string when it does not,
which is how the same gate serves GET /invoices?org_id=…. Either way it is published in
/openapi.json. The value is held to the rules a user id is held to (non-blank, at most 255
characters, no control characters); one that fails them is the same 403, and no query runs.
member_of answers the grant: anything other than None or False — a role string, a row, a
set of scopes — reaches the route on Membership.grant, typed, next to Membership.session and
Membership.resource_id. None and False are the refusal. A lookup that raises is logged and
answered 403; one that forgot its async def is a ConfigurationError, because a value returned
from a plain def would otherwise have been handed to the route as the grant.
Why the rule is a rule: activeOrganizationId is written by POST /organization/set-active, a route
the client calls (dist/plugins/organization/routes/crud-org.mjs:379). Upstream does check
membership before it writes (:420, a 403 at :425), so it is not forgeable — but it is not an
answer to this request's question either. It records which organization the client last selected,
out of the ones the user belongs to. The request in front of you names its own organization, and the
two are unrelated.
The failure that produces is not subtle. A handler that selects data by the path's organization but
authorizes on activeOrganizationId lets any member of any organization read every organization's
data: the check passes because they do have an active organization, and the query then runs against
whatever the path said. require_membership hands your lookup the id FastAPI resolved from the request and no other,
so the mistake can only be re-created inside the lookup — by ignoring that argument and reading
session.raw instead, which is the one thing a member coroutine must never do.
The real member_of is a query against the member table Better Auth already writes. It needs a
database, so it is shown rather than executed here:
from sqlalchemy import text
async def member_of(org_id: str, session: Session[User]) -> str | None:
"""Authorize this user against THIS request's organization. None means no membership."""
async with engine.connect() as connection:
result = await connection.execute(
text('SELECT role FROM "member" WHERE "organizationId" = :org AND "userId" = :user'),
{"org": org_id, "user": session.user.id},
)
membership = result.first()
return None if membership is None else membership.role
The regression case, spelled out — keep it as a test. Sign in as a user who is a member of
organization A and not of organization B. Call organization.setActive({ organizationId: "A" }).
Then request /orgs/B/invoices with that same session, and expect 403. The correct handler
refuses because the member query for (B, user) finds nothing. The broken one returns 200 and
serves B's invoices, because it asked whether the user had an active organization and never asked
which organization this request was about.
/docs, restated for Mode C
Identical to Mode A, because it is the same cookie: the route publishes an APIKeyCookie scheme, so
/docs shows an Authorize field and the security requirement appears on the operation. The two
honest limits are the same ones. Swagger UI cannot set a cookie from the Authorize modal
(swagger-api/swagger-ui#9710) — the field is
declarative, and what makes "Try it out" work is
FastAPI(swagger_ui_parameters={"withCredentials": True}), which tells Swagger to send the browser's
own cookie, the one Better Auth already set. And the scheme is documentation only: what it would
read is never read, because every credential comes from the verifier that owns it.
Errors
Every request-time failure is an HTTPException subclass, so FastAPI answers it with no handler of
yours: 401, the body {"detail": "Not authenticated"}, and a WWW-Authenticate: Bearer header.
Missing, malformed, expired, revoked, "the key set could not be fetched" and "the auth service could
not be reached" are byte-identical on the wire, deliberately — a client must not be able to tell them
apart and use the difference to probe. That last one is AuthServiceUnavailable, and it is a 401
rather than a 503 on purpose: an unreachable Better Auth server means this request was not
authenticated, and saying so in a distinct status would be both an oracle and an invitation to
retry. Mode C raises it for every non-200 upstream answer, every timeout, an unusable body, a
saturated outbound limiter, and a live 429 backoff. Two credentials on one request are a 400
({"detail": "Ambiguous request"}), decided
before anything is verified. A cookie-mode request that fails its CSRF check is a 403
({"detail": "Forbidden"}) with no challenge — it carried a credential, so there is nothing to
re-authenticate. So is a request an authorization gate refuses: NotAuthorized, raised by
require and require_membership, is byte-identical to that 403 — the rule it broke lives on
.reason (with the user id, and the sanitized resource id for a membership refusal) and never on
the wire. Reaching a 403 at all means authentication already succeeded; an anonymous or forged
request is answered 401 before any rule is asked, so a 403 is never an oracle for which
credentials this deployment accepts.
Why a request was refused lives on the exception, as .reason, and nowhere else. This library
does not log ordinary refusals — a forged, expired or malformed token, an unknown key id, a
missing or ambiguous credential — and that is deliberate rather than an omission: what to record
about a failed authentication, and where, is the deployment's decision. If you want them, register
a FastAPI exception handler for SessionError and log exc.reason explicitly; note that
logging.exception() renders str(exc), which does not carry it. A reason holds identifiers and
fingerprints — a key id, a truncated hash — never a raw credential.
What it does log, all on the fastapi_better_auth logger, is the deployment telling on itself.
At WARNING: a session-cache cookie it was not asked to read, a JWKS key it will not verify with or
a refresh that failed while a usable key set was still on hand, a stored record or a database table
it cannot use, a user model that declares a required field the upstream payload does not carry
(once per process per model, naming the model and the missing wire keys), a 429 backoff latch
opening (once per latch, never once per refused request), and the advisory requireSignature
warning (once per process). At ERROR, with the traceback: an
exception that escaped a verifier, and one that escaped an authorization predicate or a membership
lookup — each answered as the uniform refusal rather than a 500, so the log is the only place the
real exception exists. The reason those build names the exception's type and not its message.
None of these lines carries a raw token, a cookie value or a signature.
Your own error envelope
If your API wraps every response in a house envelope, the same handler that logs exc.reason is
where you reshape the body. Read exactly three things off the exception — exc.response_status,
exc.response_detail and exc.response_headers — and the envelope inherits the uniformity the
default body has:
import logging
from typing import Annotated, Any
from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi_better_auth import BetterAuth, JwtVerifier, Session, SessionError, User
logger = logging.getLogger("myapp.auth")
auth = BetterAuth(verifiers=[JwtVerifier(base_url="https://auth.example.com")])
CurrentSession = Annotated[Session[User], Depends(auth.current_session())]
app = FastAPI()
@app.exception_handler(SessionError)
async def envelope(request: Request, exc: SessionError) -> JSONResponse:
logger.warning("%s: %s", type(exc).__name__, exc.reason)
return JSONResponse(
status_code=exc.response_status,
content={
"success": False,
"data": None,
"error": {"code": exc.response_status, "message": exc.response_detail},
},
headers=exc.response_headers,
)
@app.get("/me")
async def me(session: CurrentSession) -> dict[str, Any]:
return {"success": True, "data": {"id": session.user.id}, "error": None}
Those three constants are per status, not per class: every 401 this library raises — missing,
malformed, expired, revoked, unreachable auth service — carries the same 401, the same
"Not authenticated" and the same WWW-Authenticate: Bearer, so a handler that reads only them
cannot produce two distinguishable answers however many exception classes it is handed. The
envelope changes the shape of the body, not the number of bodies.
The anti-pattern: type(exc).__name__ or exc.reason in the response. Either one hands a
client the oracle the uniform body exists to remove — one lets an unauthenticated caller sort
"expired" from "forged" from "no such session", the other adds the identifiers and fingerprints a
reason is allowed to carry. Both belong in the log line above them, which is why that line takes
the class name and the reason and the response takes neither. The same rule covers branching:
a handler with an if isinstance(exc, SessionExpired) arm is distinguishable even if every branch
looks innocent on its own.
Forward headers=. WWW-Authenticate: Bearer is the 401's challenge, and dropping it makes a
correct client stop re-authenticating. exc.response_headers is None for the 400 and the 403,
which JSONResponse accepts, so the one spelling is right for every status. There is no helper for
any of this on purpose: an envelope is your shape, not one this library could usefully template,
and three constants read off the exception is a handler a reviewer can audit in one sitting.
Testing
Override auth.current_session() — called, with the parentheses — and your tests run as
whatever session you hand back, with no token, no key set and no upstream:
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi_better_auth import BetterAuth, JwtVerifier, Session, User
auth = BetterAuth(verifiers=[JwtVerifier(base_url="https://auth.example.com")])
CurrentSession = Annotated[Session[User], Depends(auth.current_session())]
app = FastAPI()
@app.get("/me")
async def me(session: CurrentSession) -> User:
return session.user
async def fake_session() -> Session[User]:
"""The session the suite runs as. Nothing is verified to produce it."""
return Session(
user=User(id="u1", email="tester@example.com"),
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
raw={"id": "u1"},
)
def with_fake_session(app: FastAPI, auth: BetterAuth) -> None:
# Call this from a fixture, never at import: unoverridden routes must still refuse.
app.dependency_overrides[auth.current_session()] = fake_session
The parentheses are the whole of it. current_session(user_model=...) is memoized per user model,
so calling it again hands back the same callable your routes already depend on — which is what
makes it a usable override key. It is also the callable require(...) and
require_membership(...) compose on, so overriding it drives the authorization gates too: their
predicate and their member lookup run against your fake session, and only the authentication half
is bypassed. Two things are not reached by that one entry, because they are dependencies of
their own rather than wrappers an override travels through: a route declaring a different
user_model, and a route declaring optional_session. Add an entry per dependency your routes
actually hold — auth.optional_session() alongside auth.current_session(), and each with the
user_model= the routes use. Getting it wrong is not a hole: the missed route simply verifies for
real and answers 401.
Drive it from a fixture, and clear the map afterwards — dependency_overrides lives on the
application, so an override left behind outlives the test that wanted it:
import pytest
from fastapi.testclient import TestClient
from myapp.main import app, auth
from myapp.testing import with_fake_session
@pytest.fixture
def client():
with_fake_session(app, auth)
with TestClient(app) as http:
yield http
app.dependency_overrides.clear()
This is the bare-factory warning from the quickstart seen from the other side. There, forgetting the
parentheses in Depends(auth.current_session) is refused while the route is registered; here,
forgetting them writes a key nothing depends on, so the override silently does nothing and your
tests fail against real 401s. Writing the value bare — dependency_overrides[required] = auth.current_session — is the one planting this library cannot refuse at build time, because the
map is a plain dict it has no hook into; that one raises a ConfigurationError on the first request
touching the dependency, having verified nothing and served nobody.
Do I need to run a Node service?
Better Auth itself always runs in a Node/TypeScript process — sign-up, sign-in, OAuth, 2FA, and session issuance stay there; this library makes FastAPI a first-class consumer of the sessions it issues. Two topologies:
- You have a JS frontend server (Next.js, Nuxt, SvelteKit, …): no extra service — Better Auth
is already mounted at
/api/auth/*inside the frontend you deploy, and FastAPI verifies what it issues: nothing shared at all for Mode B, a shared Postgres or Redis for Mode A, and for Mode C only a route from FastAPI to that frontend's/api/auth/get-session. - No JS server (static SPA, mobile app, pure API): deploy one tiny Node service whose only job
is mounting Better Auth, and keep 100% of the business logic in FastAPI. This repository's
conformance harness (
harness/) is exactly that service, in Hono.
Either way the browser or app performs its login flows against Better Auth, then presents the resulting credential to FastAPI, where this library verifies it — the session cookie for Modes A and C, a JWT for Mode B.
Who owns the database schema
One tool owns every table, and on a FastAPI project that tool is yours — Alembic, or whatever
your side already runs. The recipe: run auth generate on the Node side, hand-port the SQL it
prints into one migration of your own, re-diff on every Better Auth upgrade, and never run
auth migrate against a database another tool migrates. That includes not copying this
repository's harness container, which does exactly that on every boot.
The reason is what auth migrate is. It is Kysely-only — on any other adapter it logs "Only kysely
adapter is supported for migrations", points you at generate, and calls process.exit(1)
(better-auth@1.7.3 dist/db/get-migration.mjs:350). And its plan is not a migration history:
getMigrations introspects the live database, diffs it against the schema your config implies, and
returns the difference as toBeCreated / toBeAdded / toBeAddedIndexes (:335, :387-389),
which runMigrations then executes statement by statement (:643-649). No migrations table, no
version stamp — nothing records that it ran, nothing can roll it back, and two tools that each
introspect-and-diff the same database can each decide the other's work is drift.
Better Auth 1.7.3 raises the stakes, because schema validation is now on by default.
advanced.database.validateSchema defaults to true, and its own doc comment says what that
buys: the schema is validated at initialization, problems are reported through the configured
logger, and authentication requests await the same check and fail when the schema does not match
(@better-auth/core@1.7.3 dist/types/init-options.d.mts:391-400). All three halves are real. A
failure at init is logged (better-auth@1.7.3 dist/auth/base.mjs:10-18); every HTTP request
awaits the check (dist/api/index.mjs:167-168) and so does every auth.api.* call
(dist/api/to-auth-endpoints.mjs:41-42); and the check throws SchemaMismatchError whenever it
finds anything (@better-auth/core@1.7.3 dist/db/schema-check.mjs:60-76). None of it is
NODE_ENV-gated. So a schema the Node side does not recognise stops that server serving rather
than degrading quietly — which, on a database two tools have been fighting over, means the failure
arrives during a deploy instead of during an incident. better-auth@1.7.1 has none of this
machinery: the same mismatch there is silent until something reads a missing column.
This repository's harness is the exception that proves the rule — harness/auth-server/Dockerfile
runs auth migrate on every start because nothing else owns that database, and a conformance
harness wants its schema pinned to the version under test. A product does not.
Why a library instead of the snippet
The hand-rolled verifiers circulating in Better Auth issues split the signed cookie on the wrong
dot, miss the __Secure- name, compare HMACs non-constant-time, and never enforce expiresAt
(upstream's findSession doesn't either — the route layer does, so a bare DB join honours expired
sessions forever). Those are Mode A's details, and Mode A handles every one of them: the last-dot
split, the __Secure- name, a constant-time keyring compare, and expiresAt enforced by the
verifier itself. The wire facts behind them are pinned as golden vectors captured from a running
Better Auth server in tests/vectors/, and re-verified against a live server in the conformance
lane — not guessed.
Mode B is held to the same standard: JwtVerifier refuses an algorithm the token's own
header chose, refuses an unknown kid rather than trying every published key, spells out the five
required claims because PyJWT requires none by default, and refuses a token whose lifetime upstream
would never have minted. A present-but-invalid credential is terminal — no falling through to a
second verifier — and no failure reason ever reaches the client. What Mode B cannot do is see a
ban or a sign-out: a JWT is verified offline and stays valid until it expires, so keep token
lifetimes short and lean on Mode A or Mode C for prompt revocation (see SECURITY.md).
Mode C looks like the easy one and has the sharpest edges, which is why the shape of the request is
fixed at construction and never derived from the one being verified. An unauthenticated
get-session answers 200 with a body of literally null, not a 401, so a snippet that
checks the status code authenticates everybody. The bearer plugin's hook overwrites the session
cookie of an outgoing request with whatever Authorization header it sees
(dist/plugins/bearer/index.mjs:44-46), so a proxy that forwards
the inbound Authorization along with the cookie hands a client a targeted denial-of-service on one
victim, and a client who sends a raw session token an authentication this side never checked — which
is why exactly two headers, cookie and accept, ever go out. httpx's default client keeps
cookies, so an auth server's Set-Cookie would be replayed onto the next user's verification: both
shipped transports install a dead jar and the boot probe detects a live one. And the shared
rate-limit bucket above is the one nobody finds until production.
License
MIT © Mulugeta Solomon
Release files for fastapi-better-auth-bridge 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| fastapi_better_auth_bridge-0.5.0.tar.gz | 170.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| fastapi_better_auth_bridge-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 346.1 kB
Release files / fastapi_better_auth_bridge-0.5.0.tar.gz
| Download URL | fastapi_better_auth_bridge-0.5.0.tar.gz |
|---|---|
| Size | 170.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
eee663bc3b4e509ab431e15d78d7ee04b682ea40f37864561e41b555a334126a
|
|
BLAKE2b-256 checksum How to use checksums |
34a877537bcf9d14fecb64683abc7b873f2d032b45f1eb70a44e5ff5fbe99df6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.
Transparency logRelease files / fastapi_better_auth_bridge-0.5.0-py3-none-any.whl
| Download URL | fastapi_better_auth_bridge-0.5.0-py3-none-any.whl |
|---|---|
| Size | 175.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d4f78fb944e8d939fac15668f50bdbf9d45e7ee4d21245051d0c094133799a09
|
|
BLAKE2b-256 checksum How to use checksums |
a90a37552f0d4a5ea1f1117815dd22ea81c46c2abecc9201abc9fabc6feb8685
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.
Transparency log