Getting started
better-auth-server is a server-side Python port of Better Auth, at full parity with the TypeScript library v1.6.25. The PyPI package is better-auth-server; the import name is better_auth.
Install
uv add better-auth-server[fastapi,sqlalchemy]pip install "better-auth-server[fastapi,sqlalchemy]"Requires Python 3.10–3.14. Four extras are available:
| Extra | Pulls in | Needed for |
|---|---|---|
fastapi | fastapi | The BetterAuthFastAPI integration |
sqlalchemy | sqlalchemy | The async SQLAlchemy adapter |
passkey | webauthn | The passkey plugin (WebAuthn/FIDO2) |
sso | dnspython | The sso plugin's DNS TXT domain verification |
The minimal server
from better_auth import BetterAuth, EmailAndPassword
from better_auth.integrations.fastapi import BetterAuthFastAPI
from fastapi import Depends, FastAPI
auth = BetterAuth(
secret="...", # openssl rand -base64 32 — must be at least 32 characters
base_url="http://localhost:8000",
email_and_password=EmailAndPassword(enabled=True),
)
app = FastAPI()
ba = BetterAuthFastAPI(auth)
app.include_router(ba.router) # mounts /api/auth/*
@app.get("/me")
async def me(result: dict = Depends(ba.require_session)):
return result["user"]That is the whole server. include_router mounts 34 endpoints under base_path (/api/auth by default): sign-up, sign-in, session read and revoke, sign-out, password change/set/reset, email verification, social sign-in and callback, and account linking.
No adapter means no persistence
Leaving adapter unset gives you MemoryAdapter(), so a quickstart runs with zero setup — and everything disappears when the process exits. Point it at a real database before you store anything you care about.
Add a database
from sqlalchemy.ext.asyncio import create_async_engine
from better_auth.adapters.sqlalchemy import SQLAlchemyAdapter
# or sqlite+aiosqlite, mysql+aiomysql
engine = create_async_engine("postgresql+asyncpg://…")
adapter = SQLAlchemyAdapter(engine)
auth = BetterAuth(secret=..., adapter=adapter)
await adapter.create_tables() # dev convenience; use Alembic in productionFour tables are created — user, session, account and verification — with Better Auth's exact camelCase column names. Plugins that need storage declare their own tables the same way.
Try it
uv run uvicorn examples.fastapi_app:app --reload# health
curl -s localhost:8000/api/auth/ok
# → {"ok":true}
# sign up (sets a session cookie)
curl -s -c /tmp/jar -X POST localhost:8000/api/auth/sign-up/email \
-H 'content-type: application/json' \
-d '{"name": "Ada", "email": "ada@example.com", "password": "s3cret-password"}'
# who am I?
curl -s -b /tmp/jar localhost:8000/api/auth/get-session
curl -s -b /tmp/jar localhost:8000/me
# sign out
curl -s -b /tmp/jar -c /tmp/jar -X POST localhost:8000/api/auth/sign-out
# → {"success":true}POST /api/auth/sign-up/email returns the session token alongside the created user:
{
"token": "5hYe5WqRTIfc3C1QuBHxVnOBUulRhHO0",
"user": {
"id": "3JQKm8qvXNXQ5mRo720N6s8gjdTdBW6i",
"name": "Ada",
"email": "ada@example.com",
"emailVerified": false,
"image": null,
"createdAt": "2026-08-02T05:52:24.191919Z",
"updatedAt": "2026-08-02T05:52:24.191919Z"
}
}and sets the session cookie:
set-cookie: better-auth.session_token=5hYe5WqRTIfc3C1QuBHxVnOBUulRhHO0.wyoOI2A09rsQDq%2BEoKZ1F3Rsojg7j…GET /api/auth/get-session returns both halves:
{
"session": {
"id": "gsTlLZ53w6icjY1v8Mj8QFKoJhBfLjWc",
"token": "5hYe5WqRTIfc3C1QuBHxVnOBUulRhHO0",
"userId": "3JQKm8qvXNXQ5mRo720N6s8gjdTdBW6i",
"expiresAt": "2026-08-09T05:52:24.261924Z",
"ipAddress": "127.0.0.1",
"userAgent": "python-httpx/0.28.1",
"createdAt": "2026-08-02T05:52:24.261924Z",
"updatedAt": "2026-08-02T05:52:24.261924Z"
},
"user": { "id": "3JQKm8qvXNXQ5mRo720N6s8gjdTdBW6i", "…": "…" }
}Protecting your own routes
The integration exposes two dependencies:
@app.get("/me")
async def me(result: dict = Depends(ba.require_session)):
# 401 when unauthenticated
return result["user"]
@app.get("/maybe")
async def maybe(result: dict | None = Depends(ba.session)):
# None when unauthenticated
return {"signed_in": result is not None}Both return the same {"session": ..., "user": ...} dict shape as /get-session, so result["user"]["id"] is the user id. Reading result["id"] is the most common mistake — that key does not exist.
API clients without cookies
Skip the cookie jar entirely: sign-in and sign-up both return a token, and every endpoint accepts it as a bearer token.
curl -s localhost:8000/me -H "Authorization: Bearer $TOKEN"Bearer reading is built into the core session layer, so no plugin is required. Add BearerPlugin only if you also want the token echoed back on a set-auth-token response header.
Errors
Failures use Better Auth's exact codes and statuses, so a client written against the TypeScript server needs no changes:
// POST /sign-in/email with a wrong password → 401
{
"code": "INVALID_EMAIL_OR_PASSWORD",
"message": "Invalid email or password"
}// POST /sign-up/email with a taken address → 422
{
"code": "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL",
"message": "User already exists. Use another email."
}Note that sign-in runs a dummy scrypt hash when the user does not exist, so an unknown address and a wrong password take the same time and return the same 401.
Call it from Python
Python services that talk to this server (or to a TypeScript Better Auth server — same wire) can use better-auth-client from PyPI instead of raw httpx:
from better_auth_client import AuthClient
client = AuthClient("http://localhost:8000") # base_path defaults to /api/auth
client.sign_in.email(email="ada@example.com", password="s3cret-password")
session = client.get_session() # dict, or None when unauthenticatedAn AsyncAuthClient offers the same surface, awaited — see the Python client guide for sessions, errors, and the full surface.
Next
- Core concepts — sessions, adapters, plugins, what parity buys you.
- Configuration — every option on
BetterAuth. - Python client —
better-auth-client, the PyPI client for this server. - Social providers — the 35 built-ins and custom ones.
- Production deploy — secrets, proxies, rate limits.