--- url: https://better-auth-py.oumarbarry.tech/ ---
## Twenty lines is a working auth server Sign-up, sign-in, sessions, sign-out, password reset, email verification and social login, mounted under `/api/auth`. ```python 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 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"] ``` ```bash uv add better-auth-server[fastapi,sqlalchemy] ``` The core is framework-agnostic, and FastAPI, Litestar, Flask and Django integrations ship in the box — each a thin layer over plain request/response dataclasses. There is a client too: [better-auth-client](https://pypi.org/project/better-auth-client/) talks to any Better Auth server from Python, sync or async. [Start here.](/guide/getting-started)
--- url: https://better-auth-py.oumarbarry.tech/guide/getting-started --- # Getting started `better-auth-server` is a server-side Python port of [Better Auth](https://better-auth.com), at full parity with the TypeScript library **v1.6.25**. The PyPI package is `better-auth-server`; the import name is `better_auth`. ## Install ```bash uv add better-auth-server[fastapi,sqlalchemy] ``` ```bash 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 ```python 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. ::: warning 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 ```python 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 production ``` Four 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 ```bash uv run uvicorn examples.fastapi_app:app --reload ``` ```bash # 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: ```json { "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: ```http set-cookie: better-auth.session_token=5hYe5WqRTIfc3C1QuBHxVnOBUulRhHO0.wyoOI2A09rsQDq%2BEoKZ1F3Rsojg7j… ``` `GET /api/auth/get-session` returns both halves: ```json { "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: ```python @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. ```bash 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`](/plugins/bearer) 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: ```json // POST /sign-in/email with a wrong password → 401 { "code": "INVALID_EMAIL_OR_PASSWORD", "message": "Invalid email or password" } ``` ```json // 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`: ```python 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 unauthenticated ``` An `AsyncAuthClient` offers the same surface, awaited — see the [Python client](/guide/client) guide for sessions, errors, and the full surface. ## Next - [Core concepts](/guide/concepts) — sessions, adapters, plugins, what parity buys you. - [Configuration](/guide/configuration) — every option on `BetterAuth`. - [Python client](/guide/client) — `better-auth-client`, the PyPI client for this server. - [Social providers](/providers/) — the 35 built-ins and custom ones. - [Production deploy](/deploy/production) — secrets, proxies, rate limits. --- url: https://better-auth-py.oumarbarry.tech/guide/concepts --- # Core concepts Four ideas carry the whole library: sessions live in your database, adapters are the only thing that touches storage, plugins add everything else, and parity means the wire and the storage format are not yours to change. ## Sessions A session is a row in the `session` table plus a signed cookie pointing at it. There is no JWT in the default path and nothing is stored in memory, so revoking a session is a delete and it takes effect on the next request. ```json { "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" } ``` **The cookie.** Named `better-auth.session_token`, or `__Secure-better-auth.session_token` once `base_url` is `https`. Its value is `token.sig`, URI-encoded, where the signature is base64 HMAC-SHA256 over the token with your `secret`. The `better-auth` part is `cookie_prefix`, so changing that changes the cookie name. **Sliding expiry.** `SessionOptions(expires_in=..., update_age=...)`. A session is valid for `expires_in` (7 days by default); once more than `update_age` (1 day) has passed since it was written, the next request extends it. A quiet week signs the user out; an active one never does. **Freshness.** `fresh_age` (1 day) marks a session as recently authenticated. Sensitive endpoints — deleting the account, changing the email — require it. **Skipping the read.** `CookieCache(enabled=True, max_age=300)` puts a signed, short-lived copy of the session in a second cookie so `/get-session` answers without touching the database. The signature is compared in constant time and the cache is ignored the moment it expires. **Bearer tokens.** `/sign-in/email` and `/sign-up/email` return a `token`, and the core session layer reads `Authorization: Bearer ` on every request. API clients never need a cookie jar. (In the TypeScript library this is a plugin; here it is built in, and [`BearerPlugin`](/plugins/bearer) only adds the response-side `set-auth-token` header.) ## Adapters Everything the library stores goes through one object. Two ship with the package: ```python from better_auth import MemoryAdapter # dev and tests from better_auth.adapters.sqlalchemy import SQLAlchemyAdapter # SQLite, PostgreSQL, MySQL ``` `SQLAlchemyAdapter` takes any async engine, so a SQLModel engine works as-is. `MemoryAdapter` is the default precisely so a quickstart runs with no setup — it is not a production option. A custom adapter is a `BaseAdapter` subclass implementing nine async methods over plain dict rows: | | | | --- | --- | | Read | `find_one`, `find_many`, `count` | | Write | `create`, `update`, `update_many` | | Delete | `delete`, `delete_many` | | Atomicity | `transaction` | `consume_one` and `increment_one` — the atomic single-use-token and attempt-counter primitives the plugins rely on — are derived from `transaction`, so implementing `transaction` correctly gets them for free. Filters arrive as a list of `Where` objects rather than raw SQL, and the adapter is also what generates ids, which is why `advanced.database.generate_id` applies uniformly to core and plugin tables. ## The schema Four core tables, with Better Auth's exact camelCase columns: | Table | Columns | | --- | --- | | `user` | `id`, `name`, `email`, `emailVerified`, `image`, `createdAt`, `updatedAt` | | `session` | `id`, `expiresAt`, `token`, `ipAddress`, `userAgent`, `userId`, `createdAt`, `updatedAt` | | `account` | `id`, `accountId`, `providerId`, `userId`, `accessToken`, `refreshToken`, `idToken`, `accessTokenExpiresAt`, `refreshTokenExpiresAt`, `scope`, `password`, `createdAt`, `updatedAt` | | `verification` | `id`, `identifier`, `value`, `expiresAt`, `createdAt`, `updatedAt` | Credentials are accounts too: an email/password user gets an `account` row with `providerId` = `credential` and the scrypt hash in `password`. That is why linking a social account to a password user is just another row. You can add columns without forking anything — `UserOptions(additional_fields=...)` and `SessionOptions(additional_fields=...)` merge into the schema, the input allowlist, and the migration. ## Plugins A plugin is one class. It may add routes, extend the schema, and hook the request pipeline; everything it does not override is a no-op. ```python from better_auth import AuthResponse, Field, Plugin class ApiKeys(Plugin): id = "api-keys" # namespace for hooks and conflicts schema = { # extra tables, migrated like core ones "apikey": { "key": Field(type="string", required=True, unique=True), "userId": Field(type="string", required=True), } } def routes(self): return [("POST", "/api-keys/create", self.create)] async def create(self, ctx): result = await ctx.require_session() return {"key": "…", "userId": result["user"]["id"]} async def before(self, ctx) -> AuthResponse | None: return None # or AuthResponse(...) to short-circuit ``` Register instances on `BetterAuth(plugins=[...])`. Beyond `routes`, `schema`, `before` and `after`, the base class offers `init(auth)` (mutate configuration once at startup), `middlewares()` (path-scoped, `/prefix/**` matching), `hooks()` (matcher-gated before/after pairs), `rate_limit()` (per-path rules), and `on_request` / `on_response` for the outermost phases. The [26 built-in plugins](/plugins/) use exactly this surface — there is no private API they reach for that yours cannot. ## What parity means The TypeScript repository is canonical. Anything that touches the wire or the storage matches it exactly, which is a stronger claim than "similar API": - **Routes and bodies.** Same paths, same success and error JSON, same error-code strings and HTTP statuses (`INVALID_EMAIL_OR_PASSWORD` 401, `USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` 422). - **Password hashes.** scrypt with `N=16384, r=16, p=1, dkLen=64`, NFKC normalization, hex `salt:key`. Hashes cross runtimes in both directions. - **Cookies.** Same name, same `__Secure-` promotion, same HMAC-SHA256 signing and URI encoding. - **Ids and tokens.** Same alphabets and lengths — 62-character ids, 64-character state and verification tokens. - **Cross-runtime crypto.** scrypt, XChaCha20-Poly1305, JWK and HOTP/TOTP are pinned by test vectors shared with the TypeScript implementation. The practical consequence is on the [migration page](/migrate/from-node): both runtimes can serve the same database at the same time. **Known divergences**, all deliberate and none visible on the wire: reset-password tokens are stored in the database (email-verification tokens stay stateless HS256 JWTs, as in TypeScript); bearer reading is core rather than a plugin. SAML (part of `sso`), `scim`, `stripe` and the JavaScript client/expo/electron/cli packages are out of scope — this is a server-side port. --- url: https://better-auth-py.oumarbarry.tech/guide/configuration --- # Configuration Every option lives on the `BetterAuth` constructor, and every one is keyword-only. Option groups are dataclasses rather than nested dicts, so a typo is a `TypeError` at startup instead of a silently ignored key. ```python from better_auth import BetterAuth auth = BetterAuth(secret=...) ``` ## Required ### `secret` ```python auth = BetterAuth(secret=os.environ["BETTER_AUTH_SECRET"]) ``` Signs session cookies, OAuth state and every derived key. Must be at least 32 characters — shorter raises at construction: ``` ValueError: secret must be at least 32 characters — generate one with `openssl rand -base64 32` ``` ### `secrets` — rotation ```python auth = BetterAuth( secret=os.environ["BETTER_AUTH_SECRET"], secrets=[ (2, os.environ["BETTER_AUTH_SECRET"]), (1, os.environ["BETTER_AUTH_SECRET_V1"]), ], ) ``` Versioned `(version, secret)` pairs. Values written under an old version keep verifying while new ones are written under the highest, so a rotation does not sign everyone out. ## URLs and mounting ```python auth = BetterAuth( secret=..., base_url="https://example.com", # default "http://localhost:8000" base_path="/api/auth", # default trusted_origins=["https://app.example.com"], cookie_prefix="better-auth", # default; changes the cookie name ) ``` `base_url` is the origin the browser sees. Setting it to an `https` URL is what promotes cookies to `Secure` and the `__Secure-` name prefix, and it is the origin the CSRF check and every `callbackURL` are validated against. `trusted_origins` adds more (a list, or a callable resolved per request). For deployments serving several hostnames from one process: ```python from better_auth import DynamicBaseURL auth = BetterAuth( secret=..., base_url=DynamicBaseURL( allowed_hosts=["example.com", "*.vercel.app"], protocol="https" ), ) ``` The base URL is then derived per request from the `Host` header, restricted to `allowed_hosts` (each of which becomes a trusted origin). An empty `allowed_hosts` can never resolve, so it raises at construction rather than on the first request. ## Storage ```python from sqlalchemy.ext.asyncio import create_async_engine from better_auth.adapters.sqlalchemy import SQLAlchemyAdapter engine = create_async_engine("postgresql+asyncpg://…") auth = BetterAuth(secret=..., adapter=SQLAlchemyAdapter(engine)) ``` Omitting `adapter` gives you `MemoryAdapter()` — fine for a quickstart, wrong for anything that must survive a restart. See [Core concepts](/guide/concepts#adapters) for the custom-adapter contract. ## Email and password ```python from better_auth import EmailAndPassword async def send_reset(user, url, token): ... # plug your mailer EmailAndPassword( enabled=True, # default False min_password_length=8, max_password_length=128, disable_sign_up=False, require_email_verification=False, auto_sign_in=True, # sign in immediately after sign-up send_reset_password=send_reset, reset_password_token_expires_in=3600, revoke_sessions_on_password_reset=False, ) ``` `send_reset_password` receives `(user, url, token)`. Without it, the reset endpoints have nowhere to send anything. ## Email verification ```python from better_auth import EmailVerification EmailVerification( send_verification_email=send_verification, # (user, url, token) send_on_sign_up=False, send_on_sign_in=False, auto_sign_in_after_verification=False, expires_in=3600, ) ``` Verification tokens are stateless HS256 JWTs, matching the TypeScript library. ## Sessions ```python from better_auth import Field, SessionOptions from better_auth.config import CookieCache SessionOptions( expires_in=7 * 86400, # 7 days update_age=86400, # extend once a day of use has passed fresh_age=86400, # window in which a session counts as recently authenticated cookie_cache=CookieCache(enabled=True, max_age=300), additional_fields={"tenantId": Field(type="string", required=False)}, ) ``` `CookieCache` trades a database read on `/get-session` for a signed cookie valid for `max_age` seconds. Revocation is not instant while a cache is live, so keep `max_age` small. ## Users and accounts ```python from better_auth import AccountLinking, AccountOptions, Field from better_auth.config import ChangeEmailOptions, UserOptions UserOptions( additional_fields={"plan": Field(type="string", required=False, default="free")}, change_email=ChangeEmailOptions( enabled=True, send_change_email_confirmation=send_confirm ), ) AccountOptions( encrypt_oauth_tokens=True, # XChaCha20-Poly1305 at rest update_account_on_sign_in=True, account_linking=AccountLinking( enabled=True, trusted_providers=["github", "google"], allow_different_emails=False, require_local_email_verified=True, disable_implicit_linking=False, ), ) ``` ::: tip Import location `UserOptions`, `ChangeEmailOptions`, `DeleteUserOptions`, `CookieCache` and `AdvancedDatabase` live in `better_auth.config`. `AccountOptions`, `AccountLinking`, `SessionOptions`, `EmailAndPassword`, `EmailVerification`, `RateLimit`, `IPAddressOptions` and `DynamicBaseURL` are re-exported at the package root. ::: `additional_fields` extends the schema, the migration and the input allowlist together — `/update-user` will not accept a field you have not declared. ## Social providers ```python from better_auth import GitHub auth = BetterAuth( secret=..., social_providers={ "github": GitHub(client_id="…", client_secret="…"), "gitlab": {"client_id": "…", "client_secret": "…"}, # name-keyed }, ) ``` Both forms work for all 35 built-ins — see [Social providers](/providers/). ## Rate limiting ```python from better_auth import RateLimit RateLimit( enabled=True, window=10, # seconds max=100, storage="memory", # "memory" | "database" | "secondary-storage" custom_rules={"/sign-in/email": {"window": 10, "max": 3}}, ) ``` Better Auth's per-path rules are built in; `custom_rules` overrides them. `storage="memory"` counts per process — behind more than one worker, use `"database"` or `"secondary-storage"`. ## Secondary storage ```python from better_auth import MemorySecondaryStorage auth = BetterAuth(secret=..., secondary_storage=MemorySecondaryStorage()) ``` A Redis-shaped protocol (`get` / `set` / `delete`) used for rate-limit counters and, when configured, verification values. Any object implementing the `SecondaryStorage` protocol works — the in-memory one ships so tests do not need Redis. ## Client IP behind a proxy ```python from better_auth import IPAddressOptions IPAddressOptions( # default ["x-forwarded-for"] ip_address_headers=["cf-connecting-ip", "x-forwarded-for"], trusted_proxies=["10.0.0.0/8"], disable_ip_tracking=False, ) ``` Without `trusted_proxies`, a client can forge `x-forwarded-for` and defeat per-IP rate limiting. See [Production deploy](/deploy/production#trust-your-proxy-not-the-client). ## Hooks Two independent systems. **Request hooks** wrap the pipeline: ```python async def before(ctx): ... # return an AuthResponse to short-circuit, or None to continue async def after(ctx): ... # return an AuthResponse to replace the outgoing one auth = BetterAuth(secret=..., hooks={"before": before, "after": after}) ``` ::: warning The keys are `"before"` and `"after"` Not `"user_created_before"` / `"user_created_after"`. Unknown keys are ignored silently, so a misspelled hook simply never runs. ::: **Database hooks** wrap model writes, keyed `model → operation → phase`: ```python async def stamp(data, ctx): return {"data": {"name": data["name"].strip()}} # merged into the row async def announce(user, ctx): await notify(user["id"]) auth = BetterAuth( secret=..., database_hooks={"user": {"create": {"before": stamp, "after": announce}}}, ) ``` A `before` hook returning `False` aborts the write; returning `{"data": {...}}` merges those keys into what is persisted. The merge applies to the stored row (and therefore to the next `/get-session`), not to the body of the request that triggered it. ## Plugins ```python from better_auth.plugins_ext import OrganizationPlugin, TwoFactorPlugin auth = BetterAuth( secret=..., plugins=[TwoFactorPlugin(issuer="Example"), OrganizationPlugin()], ) ``` See the [plugin reference](/plugins/) for all 26. ## Escape hatches | Option | Default | Effect | | --- | --- | --- | | `disabled_paths` | `None` | Removes endpoints from the router entirely | | `disable_csrf_check` | `None` | Skips the CSRF check — testing only | | `disable_origin_check` | `False` | `True` globally, or a list of paths to skip | | `cross_sub_domain_cookies` | `None` | `CrossSubDomainCookies(enabled=True, domain=".example.com")` | | `use_secure_cookies` | `None` | Forces the `Secure` flag; inferred from `base_url` otherwise | | `skip_trailing_slashes` | `False` | Matches `/sign-in/email/` as `/sign-in/email` | | `on_api_error` | `None` | `OnAPIError(throw=..., on_error=..., error_url=...)` | | `http_client` | `None` | Bring your own `httpx.AsyncClient` for outbound OAuth calls | | `verification` | `None` | `VerificationOptions(store_identifier=..., store_in_database=...)` | ## A realistic production configuration ```python import os from sqlalchemy.ext.asyncio import create_async_engine from better_auth import ( AccountLinking, AccountOptions, BetterAuth, EmailAndPassword, EmailVerification, GitHub, IPAddressOptions, RateLimit, SessionOptions, ) from better_auth.adapters.sqlalchemy import SQLAlchemyAdapter from better_auth.config import CookieCache from better_auth.plugins_ext import OrganizationPlugin, TwoFactorPlugin auth = BetterAuth( secret=os.environ["BETTER_AUTH_SECRET"], base_url=os.environ["BETTER_AUTH_URL"], adapter=SQLAlchemyAdapter(create_async_engine(os.environ["DATABASE_URL"])), email_and_password=EmailAndPassword( enabled=True, require_email_verification=True, send_reset_password=send_reset, revoke_sessions_on_password_reset=True, ), email_verification=EmailVerification( send_verification_email=send_verification, send_on_sign_up=True, ), social_providers={ "github": GitHub( client_id=os.environ["GITHUB_CLIENT_ID"], client_secret=os.environ["GITHUB_CLIENT_SECRET"], ), }, session=SessionOptions( expires_in=7 * 86400, update_age=86400, cookie_cache=CookieCache(enabled=True, max_age=300), ), account=AccountOptions( encrypt_oauth_tokens=True, account_linking=AccountLinking(trusted_providers=["github"]), ), rate_limit=RateLimit(enabled=True, storage="database"), ip_address=IPAddressOptions(trusted_proxies=["10.0.0.0/8"]), trusted_origins=[os.environ["APP_ORIGIN"]], plugins=[TwoFactorPlugin(issuer="Example"), OrganizationPlugin()], ) ``` --- url: https://better-auth-py.oumarbarry.tech/guide/client --- # Python client `better-auth-client` is the Python HTTP client for Better Auth servers, on PyPI. It talks to any server that speaks the Better Auth wire — the original TypeScript library or [`better-auth-server`](/guide/getting-started) — with the same calls either way. Sync and async shells share one surface, and `httpx` is the only dependency. ## Install ```bash uv add better-auth-client ``` ```bash pip install better-auth-client ``` ## Quickstart Sync, over `httpx.Client`: ```python from better_auth_client import AuthClient client = AuthClient("http://localhost:8000") # base_path defaults to /api/auth client.sign_up.email(name="Ada", email="ada@example.com", password="s3cret-password") client.sign_in.email(email="ada@example.com", password="s3cret-password") session = client.get_session() # dict, or None when unauthenticated ``` Async, over `httpx.AsyncClient` — the same surface, awaited: ```python from better_auth_client import AsyncAuthClient client = AsyncAuthClient("http://localhost:8000") await client.sign_up.email(name="Ada", email="ada@example.com", password="s3cret-password") await client.sign_in.email(email="ada@example.com", password="s3cret-password") session = await client.get_session() ``` Both are context managers (`with` / `async with`), and both pass extra constructor kwargs straight to httpx, so anything `httpx.Client` accepts — `timeout`, `transport`, `verify` — works here too. ## Sessions Signing in sets the session cookie and httpx's cookie jar keeps it, so consecutive calls on one client are one browsing session. Nothing to wire up. For cookieless callers there is bearer mode. When the server runs the [Bearer Token plugin](/plugins/bearer), it echoes the session token on a `set-auth-token` response header after sign-in — the client captures it automatically and sends `Authorization: Bearer ...` from then on. You can also set a token explicitly, which is the service-to-service pattern: a frontend forwards the token it stored, and a backend validates it without ever having signed in itself. ```python service = AuthClient("http://auth.internal") service.set_bearer(forwarded_token) # from the set-auth-token response header session = service.get_session() # None if the token is invalid or expired ``` ## Errors Every non-2xx response raises `APIError` carrying the exact wire error: `status` (HTTP status), `code` (the wire code string), `message`, and `body` (the parsed JSON body, when there is one). ```python from better_auth_client import APIError try: client.sign_in.email(email="ada@example.com", password="wrong-password") except APIError as error: print(error.status, error.code) # 401 INVALID_EMAIL_OR_PASSWORD ``` OAuth-shaped routes (the device plugin, `/oauth2/token`) use `{error, error_description}` on the wire; `APIError` lifts those into the same `code` and `message` fields. Redirect responses are returned as `httpx.Response` objects, never followed — an OAuth authorization URL is something to hand to a browser, not to fetch. Endpoints designed for backends return the URL as JSON instead: ```python result = client.sign_in.social(provider="google", callbackURL="/app") result["url"] # send the user's browser here ``` Kwargs are sent verbatim as wire keys — JSON body on POST, query params on GET — so camelCase wire fields stay camelCase, as `callbackURL` does above. ## The namespace surface Every endpoint method is a snake_case mirror of its wire route, generated from a single catalog: `client.sign_in.email(...)` is `POST /sign-in/email`, `client.organization.create(...)` is `POST /organization/create`. If you know the route, you know the method — 158 endpoints in all. | Namespace | Sample methods | | --- | --- | | Core (root) | `sign_up.email`, `get_session`, `list_sessions`, `change_password` | | `two_factor` | `enable`, `verify_totp`, `generate_backup_codes` | | `organization` | `create`, `list_members`, `update_member_role` — teams and dynamic roles included | | `admin` | `create_user`, `ban_user`, `impersonate_user` | | `api_key` | `create`, `list`, `delete` | | Sign-in methods | `sign_in.magic_link`, `email_otp.send_verification_otp`, `phone_number.verify`, `is_username_available`, `sign_in.anonymous`, `siwe.verify` | | `device` | `flow`, `approve`, `deny` | | `multi_session` | `list_device_sessions`, `set_active`, `revoke` | | `one_time_token` | `generate`, `verify` | | `sso` | `register`, `providers`, `verify_domain` | | `oauth2` | `register` (DCR), `authorize`, `introspect`, `client.rotate_secret` | | JWT (root) | `token()`, `jwks()` | Plus `passkey`, `one_tap`, and the rest — the [README on PyPI](https://pypi.org/project/better-auth-client/) lists the full catalog. ## Device flow For CLIs and other input-constrained programs, `device.flow()` runs the whole RFC 8628 loop against a server with the [Device Authorization plugin](/plugins/device-authorization): ```python from better_auth_client import AuthClient client = AuthClient("https://auth.example.com") flow = client.device.flow("my-cli") print(f"Visit {flow.verification_uri} and enter {flow.user_code}") token = flow.poll() # blocks until approved, denied, or expired client.set_bearer(token["access_token"]) print(client.get_session()["user"]["email"]) ``` `poll()` honors the server's polling `interval`, backs off five seconds on `slow_down`, keeps waiting on `authorization_pending`, and raises `APIError` on denial or expiry. On `AsyncAuthClient` both `device.flow(...)` and `poll()` are awaited. ## What is not in the client The catalog has one inclusion rule: a method exists when the request is genuinely emitted by a Python program — headless, or relaying for a browser (a BFF, server-rendered pages, CLIs). Routes only the end user's own browser ever requests are deliberately absent: OAuth redirect callbacks, `/oauth2/continue` (backends redirect *to* it, never call it), and the OAuth Popup plugin's popup navigation. If a route is missing, that is the reason — not an oversight. ## Next - [Getting started](/guide/getting-started) — stand up the server this client talks to. - [Bearer Token](/plugins/bearer) — the server side of `set-auth-token`. - [Device Authorization](/plugins/device-authorization) — the server side of `device.flow()`. --- url: https://better-auth-py.oumarbarry.tech/guide/agents --- # AI agents This project ships three things for coding agents: an installable skill, plain-text mirrors of this site, and in-repo agent instructions. All three describe the same package — pick the one your setup consumes. ## The skill ```bash npx skills add oumarbarry/better-auth-py ``` That installs the `better-auth-server` skill — a `SKILL.md` plus four reference files — for Claude Code and any harness that reads the agent skills format (the CLI lists the supported ones). It teaches an agent to stand up a working server (FastAPI, Litestar, Flask or Django), protect routes, configure any of the 26 plugins and 35 social providers, migrate a Node Better Auth server onto the same database, and avoid the classic mistakes (short secrets, the in-memory default adapter, missing `trusted_origins`). Every code snippet in the skill has been executed and verified against the released package. The skill covers *using* `better-auth-server` in your application. It is not a contribution guide — that is [AGENTS.md](#in-repo) below. ## llms.txt The site publishes both [llmstxt.org](https://llmstxt.org) endpoints: | Endpoint | Contents | |---|---| | [`/llms.txt`](/llms.txt) | An index — every page with its URL and a one-line description | | [`/llms-full.txt`](/llms-full.txt) | Every page of this site, concatenated as one Markdown file | Fetch `/llms.txt` when the agent should pick the pages it needs; fetch `/llms-full.txt` when you want the whole documentation in context in one request. Both are regenerated on every deploy, so they never lag the site. ## In-repo [`AGENTS.md`](https://github.com/oumarbarry/better-auth-py/blob/main/AGENTS.md) at the repository root governs agents *contributing to* the port: the parity prime directive (the TypeScript repo is canonical for anything touching wire or storage), the full test-and-lint gate to run before claiming work done, and the repo's conventions. `CLAUDE.md` points at it, so Claude Code picks it up automatically. In short: install the skill to build *with* `better-auth-server`; read `AGENTS.md` to work *on* it. --- url: https://better-auth-py.oumarbarry.tech/plugins/ --- # Plugins 26 plugins ship with the package under `better_auth.plugins_ext`. Each one is a class; pass instances to `BetterAuth(plugins=[...])`. ```python from better_auth import BetterAuth from better_auth.plugins_ext import OrganizationPlugin, TwoFactorPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[TwoFactorPlugin(issuer="Example"), OrganizationPlugin()], ) ``` Plugins add routes under `base_path`, extend the database schema (their tables migrate exactly like the core ones), and hook the request pipeline. Every constructor option mirrors the TypeScript option of the same name in snake_case, with the same default. `better_auth.plugins_ext.__all__` is the authoritative list. Each plugin has its own page: ## Sign-in methods - [Username](./username) — sign in with a username instead of an email - [Magic Link](./magic-link) — passwordless sign-in through a single-use link - [Email OTP](./email-otp) — one-time codes by email - [Phone Number](./phone-number) — SMS one-time codes - [Passkey (WebAuthn)](./passkey) — WebAuthn/FIDO2 - [Anonymous](./anonymous) — throwaway guest users, linked on real sign-up - [Sign-In with Ethereum](./siwe) — SIWE (ERC-4361) wallet authentication - [Google One Tap](./one-tap) — sign in from Google's One Tap prompt - [Two-Factor Authentication](./two-factor) — TOTP, OTP and backup codes as a second factor ## Organizations and access control - [Admin](./admin) — user administration, bans, impersonation - [Organization](./organization) — organizations, members, invitations, teams ## Tokens and keys - [API Key](./api-key) — long-lived database-backed API keys - [JWT](./jwt) — signed JWTs plus a published JWKS - [Bearer Token](./bearer) — the `set-auth-token` response header - [One-Time Token](./one-time-token) — single-use session handoff tokens ## Being an OAuth server - [OAuth Provider](./oauth-provider) — a full OAuth 2.1 / OIDC authorization server - [Device Authorization](./device-authorization) — the RFC 8628 device flow ## Federating outward - [SSO (OIDC)](./sso) — OIDC identity providers per domain or organization - [Generic OAuth](./generic-oauth) — any OAuth2/OIDC provider, configured at runtime - [OAuth Proxy](./oauth-proxy) — social login from preview deployments - [OAuth Popup](./oauth-popup) — social sign-in in a popup window ## Session shaping - [Multi-Session](./multi-session) — several accounts signed in at once - [Custom Session](./custom-session) — reshape the `/get-session` payload - [Last Login Method](./last-login-method) — the "you last signed in with…" hint ## Abuse prevention - [Captcha](./captcha) — CAPTCHA checks before protected endpoints - [Have I Been Pwned](./have-i-been-pwned) — reject breached passwords ## Writing your own Everything above uses the same public surface your plugin has — see [Core concepts](/guide/concepts#plugins). --- url: https://better-auth-py.oumarbarry.tech/plugins/username --- # Username Sign in with a username instead of an email, with configurable validation and normalization. Mirrors the TS `username()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import UsernamePlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[UsernamePlugin(min_username_length=3, max_username_length=30)], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `min_username_length` | `int` | `3` | Minimum length. | | `max_username_length` | `int` | `30` | Maximum length. | | `username_validator` | `callable \| None` | `None` | `(username) -> bool` extra format check. | | `display_username_validator` | `callable \| None` | `None` | Validator for `displayUsername`. | | `username_normalization` | `callable \| bool \| None` | `None` | Normalizer applied on write (default lowercases); `False` disables. | | `display_username_normalization` | `callable \| bool` | `False` | Normalizer for `displayUsername`. | | `validation_order` | `dict \| None` | `None` | Run validation before or after normalization. | | `schema` | `dict \| None` | `None` | Field-name overrides for the added columns. | ## Endpoints | Method | Path | | --- | --- | | POST | `/sign-in/username` | | POST | `/is-username-available` | ## Schema | Table | Added columns | | --- | --- | | `user` | `username` (unique), `displayUsername` | ## Notes - `/sign-in/username` equalizes timing — a wrong username still runs a dummy password hash — and never leaks `EMAIL_NOT_VERIFIED` before a correct password. - Validation errors are 400s on `/sign-up/email` and `/update-user` (HTTP before-hooks) and 422s on `/sign-in/username` and `/is-username-available`, matching TS. --- url: https://better-auth-py.oumarbarry.tech/plugins/magic-link --- # Magic Link Passwordless sign-in through a single-use emailed link. Mirrors the TS `magicLink()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import MagicLinkPlugin async def send_magic_link(email, url, token, request): ... # mail the url auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[MagicLinkPlugin(send_magic_link=send_magic_link)], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `send_magic_link` | `callable` | required | `(email, url, token, request) -> None`, delivers the link. | | `expires_in` | `int` | `300` | Link lifetime in seconds. | | `allowed_attempts` | `int \| None` | `None` | Deprecated in TS; tokens are single-use regardless. Any value other than `1` logs a warning. | | `disable_sign_up` | `bool` | `False` | Only sign in existing users; never create one from a link. | | `rate_limit` | `dict[str, int] \| None` | `None` | Rate-limit overrides. | | `generate_token` | `callable \| None` | `None` | Custom token generator, `(email) -> str`. | | `store_token` | `str \| dict` | `"plain"` | `"plain"`, `"hashed"`, or a custom-hasher config for the stored token. | ## Endpoints | Method | Path | | --- | --- | | POST | `/sign-in/magic-link` | | GET | `/magic-link/verify` | ## Schema No extra tables — tokens live in the core `verification` table, keyed by the *stored* token with value `JSON({email, name?})`, byte-compatible with TS. ## Notes - Verification consumes the token atomically: N racing verifies mint at most one session; invalid/expired tokens redirect to `errorCallbackURL?error=INVALID_TOKEN`. - Each callback URL is origin-checked before redirecting. - Adopting an existing unverified user revokes its unproven credential and sessions before marking the email verified. --- url: https://better-auth-py.oumarbarry.tech/plugins/email-otp --- # Email OTP One-time codes by email for sign-in, email verification, email change and password reset. Mirrors the TS `emailOTP()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import EmailOTPPlugin async def send_verification_otp(email, otp, otp_type): ... # send the code by email auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[EmailOTPPlugin(send_verification_otp=send_verification_otp)], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `send_verification_otp` | `callable` | required | `(email, otp, type) -> None`, delivers the code. | | `otp_length` | `int` | `6` | Number of digits. | | `expires_in` | `int` | `300` | Code lifetime in seconds. | | `generate_otp` | `callable \| None` | `None` | Custom code generator. | | `send_verification_on_sign_up` | `bool` | `False` | Send an email-verification OTP after sign-up. | | `disable_sign_up` | `bool` | `False` | Never create a user implicitly from an OTP sign-in. | | `allowed_attempts` | `int` | `3` | Wrong-code budget per stored OTP. | | `store_otp` | `str \| dict` | `"plain"` | `"plain"`, `"hashed"`, `"encrypted"`, or a custom hash/encrypt config. | | `resend_strategy` | `str` | `"rotate"` | What a resend does to the pending code. | | `change_email` | `dict \| None` | `None` | Email-change sub-options. | | `override_default_email_verification` | `bool` | `False` | Replace the core link-based email verification with OTP emails. | | `rate_limit` | `dict[str, int] \| None` | `None` | Per-endpoint rate-limit overrides. | ## Endpoints 9 routes: | Method | Path | | --- | --- | | POST | `/email-otp/send-verification-otp` | | POST | `/email-otp/check-verification-otp` | | POST | `/email-otp/verify-email` | | POST | `/sign-in/email-otp` | | POST | `/email-otp/request-password-reset` | | POST | `/forget-password/email-otp` | | POST | `/email-otp/reset-password` | | POST | `/email-otp/request-email-change` | | POST | `/email-otp/change-email` | The TS server-only endpoints `createVerificationOTP` / `getVerificationOTP` are not mounted as HTTP routes (their paths 404). They are exposed as plain async methods on the plugin instance: `create_verification_otp(email, otp_type)` and `get_verification_otp(email, otp_type)`. ## Schema No extra tables — codes live in the core `verification` table, identifier scheme `-otp-`, value `":"`, byte-compatible with TS. ## Notes - The send endpoint is origin-checked, so a cookieless cross-origin POST cannot mail a code to an arbitrary address. - Sign-in codes for unknown emails are silently dropped (no user enumeration). - `get_verification_otp` raises a 400 when `store_otp` is hashed — the plain text is unrecoverable. - Codes are consumed atomically: one code can never satisfy two verifications. --- url: https://better-auth-py.oumarbarry.tech/plugins/phone-number --- # Phone Number SMS one-time codes for sign-in, phone verification and password reset. Mirrors the TS `phoneNumber()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import PhoneNumberPlugin async def send_otp(phone_number, code): ... # send the SMS auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[PhoneNumberPlugin(send_otp=send_otp)], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `otp_length` | `int` | `6` | Number of digits. | | `expires_in` | `int` | `300` | Code lifetime in seconds. | | `allowed_attempts` | `int` | `3` | Wrong-code budget per stored OTP. | | `send_otp` | `callable \| None` | `None` | `(phone_number, code) -> None`. Required in practice: endpoints answer `SEND_OTP_NOT_IMPLEMENTED` (501) without it. | | `verify_otp` | `callable \| None` | `None` | Custom verifier replacing the stored-code comparison. | | `send_password_reset_otp` | `callable \| None` | `None` | Separate sender for password-reset codes. | | `phone_number_validator` | `callable \| None` | `None` | `(phone_number) -> bool` format check. | | `require_verification` | `bool` | `False` | Block `/sign-in/phone-number` until the number is verified. | | `callback_on_verification` | `callable \| None` | `None` | Called after a successful verification. | | `sign_up_on_verification` | `dict \| None` | `None` | Auto-create a user on first verification (temp-email settings). | ## Endpoints | Method | Path | | --- | --- | | POST | `/sign-in/phone-number` | | POST | `/phone-number/send-otp` | | POST | `/phone-number/verify` | | POST | `/phone-number/request-password-reset` | | POST | `/phone-number/reset-password` | ## Schema | Table | Added columns | | --- | --- | | `user` | `phoneNumber`, `phoneNumberVerified` | ## Notes - Storage parity with TS: codes stored as `":"` under the raw phone number; reset OTPs under `"-request-password-reset"`. - Codes are consumed atomically — one code never satisfies two verifications. - Deliberate simplifications: the TS per-instance `schema` field-name override is not exposed, and SMS-send failures are not isolated in a background task (no `advanced.backgroundTasks` seam in this port). --- url: https://better-auth-py.oumarbarry.tech/plugins/passkey --- # Passkey (WebAuthn) WebAuthn/FIDO2 registration and authentication — Touch ID, Windows Hello, hardware keys. Mirrors the TS `@better-auth/passkey` plugin. Requires the `passkey` extra (`pip install "better-auth-server[passkey]"`, which pulls in `webauthn`). ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import PasskeyPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[ PasskeyPlugin( rp_id="example.com", rp_name="Example", origin="https://example.com" ) ], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `rp_id` | `str \| None` | `None` (hostname of `base_url`) | The relying-party id — the registrable domain, no scheme. | | `rp_name` | `str` | `"Better Auth"` | Human-readable relying-party name. | | `origin` | `str \| list[str] \| None` | `None` (request `Origin` header) | Expected WebAuthn origin(s). | | `authenticator_selection` | `dict \| None` | `None` | WebAuthn authenticator selection criteria, e.g. `{"residentKey": "preferred", "userVerification": "preferred"}`. | | `challenge_cookie` | `str` | `"better-auth-passkey"` | Name of the signed challenge cookie. | | `registration` | `dict \| None` | `None` | Registration ceremony overrides. | | `authentication` | `dict \| None` | `None` | Authentication ceremony overrides. | ## Endpoints 7 routes under `/passkey/`: | Method | Path | | --- | --- | | GET | `/passkey/generate-register-options` | | POST | `/passkey/verify-registration` | | GET | `/passkey/generate-authenticate-options` | | POST | `/passkey/verify-authentication` | | GET | `/passkey/list-user-passkeys` | | POST | `/passkey/delete-passkey` | | POST | `/passkey/update-passkey` | ## Schema | Table | Columns | | --- | --- | | `passkey` | `name`, `publicKey`, `userId`, `credentialID`, `counter`, `deviceType`, `backedUp`, `transports`, `createdAt`, `aaguid` | ## Notes - Cross-runtime storage parity is exact: `publicKey` is standard padded base64 of the raw COSE bytes, `credentialID` is unpadded base64url, `deviceType` is camelCase (`"singleDevice"`/`"multiDevice"`) — a row written by the TS plugin verifies here and vice versa. - Challenges are single-use: a signed cookie (max age 300s) plus a verification row consumed atomically on verify. - Importing `better_auth.plugins_ext` without the `passkey` extra installed raises `ModuleNotFoundError`. --- url: https://better-auth-py.oumarbarry.tech/plugins/anonymous --- # Anonymous A throwaway user and session for visitors who have not signed up. When the same visitor later authenticates for real, the anonymous account is linked via `on_link_account` and cleaned up. Mirrors the TS `anonymous()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import AnonymousPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[AnonymousPlugin(email_domain_name="example.com")], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `email_domain_name` | `str \| None` | `None` | Domain for the generated placeholder address (`temp-@`); without it, `temp@.com`. | | `on_link_account` | `callable \| None` | `None` | `({"anonymousUser": ..., "newUser": ...}) -> None`, called when the visitor signs up for real. | | `disable_delete_anonymous_user` | `bool` | `False` | Keep the anonymous user row after linking. | | `generate_name` | `callable \| None` | `None` | Custom display-name generator, `(ctx) -> str`. | | `generate_random_email` | `callable \| None` | `None` | Custom placeholder-email generator. | ## Endpoints | Method | Path | | --- | --- | | POST | `/sign-in/anonymous` | | POST | `/delete-anonymous-user` | ## Schema | Table | Added columns | | --- | --- | | `user` | `isAnonymous` | ## Notes - An anonymous user cannot sign in anonymously again (`ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY`). - Deliberate simplification: the TS per-instance `schema` field-name override is not exposed. --- url: https://better-auth-py.oumarbarry.tech/plugins/siwe --- # Sign-In with Ethereum Sign-In with Ethereum (SIWE, ERC-4361) wallet authentication. You supply nonce generation and signature verification; the plugin owns message parsing and the session half. Mirrors the TS `siwe()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import SiwePlugin async def get_nonce(): ... # return a fresh nonce string async def verify_message(args): ... # {"message", "signature", "address", "chainId", ...} -> bool auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[ SiwePlugin( domain="example.com", get_nonce=get_nonce, verify_message=verify_message ) ], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `domain` | `str` | required | The domain the SIWE message must be bound to. | | `get_nonce` | `callable` | required | `() -> str`, generates a nonce. | | `verify_message` | `callable` | required | `(dict) -> bool`, recovers/checks the secp256k1 signature (bring your own web3 library). | | `email_domain_name` | `str \| None` | `None` (origin of `base_url`) | Domain for the generated placeholder email. | | `anonymous` | `bool` | `True` | Allow wallet-only accounts; `False` requires an email in the verify body. | | `ens_lookup` | `callable \| None` | `None` | Resolve ENS name/avatar for new users. | ## Endpoints | Method | Path | | --- | --- | | POST | `/siwe/nonce` | | POST | `/siwe/get-nonce` (alias) | | POST | `/siwe/verify` | ## Schema | Table | Columns | | --- | --- | | `walletAddress` | `userId`, `address`, `chainId`, `isPrimary`, `createdAt` | ## Notes - The plugin ships its own ERC-4361 message parser (ported verbatim from TS `parse-message.ts`) — it does not trust `verify_message` for message-body validation; your callable only has to check the signature. - Addresses are EIP-55 checksummed via keccak256 (`pycryptodome`); hashlib's `sha3_256` is FIPS-202 SHA3 and cannot be used for this. --- url: https://better-auth-py.oumarbarry.tech/plugins/one-tap --- # Google One Tap Google One Tap: the browser posts a Google id token to `/one-tap/callback` and gets a session back, running the same find/register/link decision tree as the redirect OAuth flow. Mirrors the TS `oneTap()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import OneTapPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[OneTapPlugin(client_id="xxx.apps.googleusercontent.com")], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `disable_signup` | `bool` | `False` | Only sign in existing users. | | `client_id` | `str \| list[str] \| None` | `None` | Accepted `aud` value(s); falls back to the registered Google provider's client id. | ## Endpoints | Method | Path | | --- | --- | | POST | `/one-tap/callback` | ## Notes - The id token is verified against Google's JWKS (RS256/ES256) with the same machinery as the core Google provider. - Also honors the registered [Google provider](/providers/)'s `disable_sign_up` and its `authorize_params["hd"]` hosted-domain restriction. --- url: https://better-auth-py.oumarbarry.tech/plugins/two-factor --- # Two-Factor Authentication Second-factor authentication via TOTP, emailed/SMS OTP and backup codes, with a short-lived two-factor cookie between the password step and the code step, trusted devices, and optional account lockout. Mirrors the TS `twoFactor()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import TwoFactorPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[TwoFactorPlugin(issuer="Example")], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `issuer` | `str \| None` | `None` | Issuer shown in the authenticator app (defaults to the app name). | | `two_factor_table` | `str` | `"twoFactor"` | Model name for the plugin's table. | | `totp_options` | `dict \| None` | `None` | TOTP group, e.g. `{"digits": 6, "period": 30}`. | | `otp_options` | `dict \| None` | `None` | OTP group, e.g. `{"send_otp": fn, "period": 3, "store_otp": "plain"}`. | | `backup_code_options` | `dict \| None` | `None` | Backup-code group, e.g. `{"amount": 10, "length": 10}`. | | `skip_verification_on_enable` | `bool` | `False` | Enable 2FA without requiring a first verified code. | | `allow_passwordless` | `bool` | `False` | Allow enabling 2FA on accounts without a password credential. | | `two_factor_cookie_max_age` | `int` | `600` | Lifetime (seconds) of the sign-in challenge cookie. | | `trust_device_max_age` | `int` | `2592000` | Lifetime (seconds) of the trusted-device cookie (30 days). | | `account_lockout` | `dict \| None` | `None` | Lockout group (failed-attempt threshold and duration). | Sub-option dicts use snake_case keys mirroring the TS option groups. ## Endpoints 8 routes under `/two-factor/`: | Method | Path | | --- | --- | | POST | `/two-factor/enable` | | POST | `/two-factor/disable` | | POST | `/two-factor/get-totp-uri` | | POST | `/two-factor/verify-totp` | | POST | `/two-factor/send-otp` | | POST | `/two-factor/verify-otp` | | POST | `/two-factor/verify-backup-code` | | POST | `/two-factor/generate-backup-codes` | The TS server-only endpoints `/totp/generate` and `/two-factor/view-backup-codes` are not mounted as HTTP routes; they are exposed as plain async methods on the plugin instance — `generate_totp_code(secret)` and `view_backup_codes(user_id)` — following the [Email OTP](./email-otp) precedent. ## Schema | Table | Columns | | --- | --- | | `user` | adds `twoFactorEnabled` | | `twoFactor` | `secret`, `backupCodes`, `userId`, `verified`, `failedVerificationCount`, `lockedUntil` | ## Notes - Cross-runtime storage parity: `secret` and `backupCodes` are XChaCha20-Poly1305 encrypted exactly like TS; a row written by the TS library verifies here and vice versa. - Sign-in with 2FA enabled returns `{"twoFactorRedirect": true, "twoFactorMethods": [...]}` and sets the signed `two_factor` challenge cookie instead of a session. --- url: https://better-auth-py.oumarbarry.tech/plugins/admin --- # Admin User administration: roles and permissions, ban and unban, impersonation, session management, setting a user's password, and permission checks. Mirrors the TS `admin()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import AdminPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[AdminPlugin(default_role="user", admin_roles=["admin"])], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `default_role` | `str` | `"user"` | Role assigned to newly created users. | | `admin_roles` | `str \| list[str] \| None` | `None` (treated as `["admin"]`) | Roles allowed to call admin endpoints; accepts a comma string or a list. | | `default_ban_reason` | `str \| None` | `None` | Reason recorded when banning without one. | | `default_ban_expires_in` | `int \| None` | `None` | Ban duration in seconds when none is given; `None` = permanent. | | `impersonation_session_duration` | `int` | `3600` | Lifetime (seconds) of impersonation sessions. | | `roles` | `dict[str, Role] \| None` | `None` | Custom role set built from an access-control statement set. | | `admin_user_ids` | `list[str] \| None` | `None` | Explicit user ids granted admin access regardless of role. | | `ac` | `AccessControl \| None` | `None` | Access-control instance backing custom `roles`. | | `banned_user_message` | `str \| None` | `None` | Message returned when a banned user tries to sign in. | | `allow_impersonating_admins` | `bool` | `False` | Allow impersonating users who are themselves admins. | ## Endpoints 15 routes under `base_path`: | Method | Path | | --- | --- | | POST | `/admin/set-role` | | GET | `/admin/get-user` | | POST | `/admin/create-user` | | POST | `/admin/update-user` | | GET | `/admin/list-users` | | POST | `/admin/list-user-sessions` | | POST | `/admin/ban-user` | | POST | `/admin/unban-user` | | POST | `/admin/impersonate-user` | | POST | `/admin/stop-impersonating` | | POST | `/admin/revoke-user-session` | | POST | `/admin/revoke-user-sessions` | | POST | `/admin/remove-user` | | POST | `/admin/set-user-password` | | POST | `/admin/has-permission` | ## Schema | Table | Added columns | | --- | --- | | `user` | `role`, `banned`, `banReason`, `banExpires` | | `session` | `impersonatedBy` | ## Notes - Ban enforcement runs as a `session.create` database hook, so a banned user's sign-in is blocked at session creation — on every session creation in this port (TS gates the hook on having a request context; the effect is the same). - TS's trusted null-session server calls (`create-user` / `has-permission` without a request) are not reachable through this HTTP-only router; both endpoints simply require a session. - Dynamic per-organization roles live in [Organization](./organization), not here. --- url: https://better-auth-py.oumarbarry.tech/plugins/organization --- # Organization Organizations, members, invitations, teams, and dynamic access control — the largest plugin in the set. Mirrors the TS `organization()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.access_control import create_access_control from better_auth.plugins_ext import OrganizationPlugin ac = create_access_control({"project": ["create", "share", "update", "delete"]}) auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[ OrganizationPlugin( ac=ac, roles={"admin": ac.new_role({"project": ["create", "update", "delete"]})}, creator_role="owner", ) ], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `allow_user_to_create_organization` | `bool \| callable` | `True` | Gate organization creation, statically or per user. | | `organization_limit` | `int \| callable \| None` | `None` | Max organizations a user may create. | | `creator_role` | `str` | `"owner"` | Role given to the creating member. | | `membership_limit` | `int \| callable` | `100` | Max members per organization. | | `ac` | `AccessControl \| None` | `None` | Access-control statement set backing `roles`. | | `roles` | `dict[str, Role] \| None` | `None` | Custom role definitions. | | `dynamic_access_control` | `dict \| None` | `None` | Enable per-organization roles stored in the database. | | `disable_organization_deletion` | `bool` | `False` | Reject `/organization/delete`. | | `invitation_expires_in` | `int` | `172800` | Invitation lifetime in seconds (2 days). | | `invitation_limit` | `int \| callable \| None` | `100` | Max pending invitations per inviter. | | `cancel_pending_invitations_on_re_invite` | `bool` | `False` | Cancel a previous pending invitation when re-inviting the same email. | | `require_email_verification_on_invitation` | `bool \| None` | `None` | Require a verified email before accepting an invitation. | | `send_invitation_email` | `callable \| None` | `None` | `(data) -> None`, called when an invitation is created. | | `teams` | `dict \| None` | `None` | Team support, gated on `{"enabled": True}` (plus team options). | | `organization_hooks` | `dict[str, callable] \| None` | `None` | Lifecycle hooks, snake_case keys (`before_create_organization`, ...); `before_*` hooks may return `{"data": {...}}` to merge. | | `additional_fields` | `dict \| None` | `None` | Extra schema fields per organization table. | ## Endpoints 20 core routes under `/organization/`: | Method | Path | | --- | --- | | POST | `/organization/create` | | POST | `/organization/update` | | POST | `/organization/delete` | | POST | `/organization/set-active` | | GET | `/organization/get-full-organization` | | GET | `/organization/list` | | POST | `/organization/check-slug` | | POST | `/organization/leave` | | GET | `/organization/list-members` | | POST | `/organization/remove-member` | | POST | `/organization/update-member-role` | | GET | `/organization/get-active-member` | | POST | `/organization/has-permission` | | POST | `/organization/invite-member` | | POST | `/organization/accept-invitation` | | POST | `/organization/reject-invitation` | | POST | `/organization/cancel-invitation` | | GET | `/organization/get-invitation` | | GET | `/organization/list-invitations` | | GET | `/organization/list-user-invitations` | With `teams={"enabled": True}`, 9 more: | Method | Path | | --- | --- | | POST | `/organization/create-team` | | POST | `/organization/update-team` | | POST | `/organization/remove-team` | | GET | `/organization/list-teams` | | POST | `/organization/set-active-team` | | GET | `/organization/list-user-teams` | | GET | `/organization/list-team-members` | | POST | `/organization/add-team-member` | | POST | `/organization/remove-team-member` | ## Schema | Table | Columns | | --- | --- | | `organization` | `id`, `name`, `slug`, `logo`, `metadata`, `createdAt` | | `member` | `id`, `organizationId`, `userId`, `role`, `createdAt` | | `invitation` | `id`, `organizationId`, `email`, `role`, `status`, `expiresAt`, `createdAt`, `inviterId` | | `session` | adds `activeOrganizationId` | With teams enabled: `team`, `teamMember` tables, `invitation.teamId` and `session.activeTeamId`. ## Notes - `metadata` is stored as a JSON string column — identical to TS, so a Python and a TS app can share the database. - Deleting an organization cascades to members, invitations and teams; the cascade is not wrapped in a transaction here (a deliberate simplification: the MemoryAdapter has no transactions; TS wraps it in one). - Team creation caps are checked with count-then-create, which races under heavy concurrency — the same known FIXME as TS. - For instance-wide (non-organization) roles, see [Admin](./admin). --- url: https://better-auth-py.oumarbarry.tech/plugins/api-key --- # API Key Long-lived API keys backed by the database: create, list, update, delete and verify, with prefixes, expiry windows, per-key rate limits, refill quotas, metadata and permissions. Mirrors the TS `@better-auth/api-key` plugin (database storage mode). ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import ApiKeyPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[ ApiKeyPlugin( {"default_prefix": "sk_", "default_key_length": 64, "enable_metadata": True} ) ], ) ``` ## Options Unlike the other plugins, configuration is a **dict** (or a list of dicts, each carrying a `config_id`), not kwargs — mirroring the TS multi-config shape. | Option | Type | Default | Description | | --- | --- | --- | --- | | `config` | `dict \| list[dict] \| None` | `None` | Snake_case option dict(s) mirroring the TS `apiKey()` options — e.g. `default_prefix`, `default_key_length`, `enable_metadata`, `rate_limit`, `key_expiration`, `permissions`. | | `schema` | `Schema \| None` | `None` | Override the generated `apikey` table definition. | ## Endpoints | Method | Path | | --- | --- | | POST | `/api-key/create` | | GET | `/api-key/get` | | POST | `/api-key/update` | | POST | `/api-key/delete` | | GET | `/api-key/list` | The TS `serverOnly` endpoints are plugin methods, never mounted as HTTP routes (the [Email OTP](./email-otp) precedent): `verify_api_key(...)` and `delete_all_expired_api_keys(ctx)`. The HTTP create/update routes always run the TS "client" path (rejecting server-only props and `userId`); the `create_api_key` / `update_api_key` methods run the "server" path. ## Schema | Table | Columns | | --- | --- | | `apikey` | `configId`, `name`, `start`, `referenceId`, `prefix`, `key`, `refillInterval`, `refillAmount`, `lastRefillAt`, `enabled`, `rateLimitEnabled`, `rateLimitTimeWindow`, `rateLimitMax`, `requestCount`, `remaining`, `lastRequest`, `expiresAt`, `createdAt`, `updatedAt`, `permissions`, `metadata` | ## Notes - Cross-runtime storage parity: the `key` column is base64url-nopad SHA-256 of `prefix + random`, byte-identical to the TS `defaultKeyHasher` — a row written by the TS plugin verifies here and vice versa. - `storage: "database"` only; `secondary-storage` / `customStorage` raise `NotImplementedError` at construction. - Quota/rate-limit updates use a guarded compare-and-swap against the DB row, so exactly one concurrent verify wins a `remaining` decrement. --- url: https://better-auth-py.oumarbarry.tech/plugins/jwt --- # JWT Issues signed JWTs for the current session and publishes a JWKS so other services can verify them without calling back. Mirrors the TS `jwt()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import JWTPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[JWTPlugin(expiration_time="15m")], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `remote_url` | `str \| None` | `None` | Point `/jwks` consumers at a remote JWKS instead of local keys. | | `key_pair_config` | `dict \| None` | `None` (`{"alg": "EdDSA", "crv": "Ed25519"}`) | Key algorithm config; the TS `JWKOptions` union: EdDSA/Ed25519, ES256, ES512, PS256, RS256. | | `disable_private_key_encryption` | `bool` | `False` | Store private keys unencrypted. | | `rotation_interval` | `int \| None` | `None` | Rotate the signing key every N seconds. | | `grace_period` | `int` | `2592000` | How long rotated-out keys stay in the JWKS (30 days). | | `jwks_path` | `str` | `"/jwks"` | Route where the JWKS is published. | | `issuer` | `str \| None` | `None` (`base_url`) | `iss` claim. | | `audience` | `str \| list[str] \| None` | `None` (`base_url`) | `aud` claim. | | `expiration_time` | `int \| float \| datetime \| str` | `"15m"` | Token lifetime (seconds or a duration string). | | `define_payload` | `callable \| None` | `None` | Custom payload builder from the session. | | `get_subject` | `callable \| None` | `None` | Custom `sub` claim (defaults to the user id). | | `sign` | `callable \| None` | `None` | Replace the signing routine entirely. | | `disable_setting_jwt_header` | `bool` | `False` | Don't attach `set-auth-jwt` on `/get-session` responses. | ## Endpoints | Method | Path | | --- | --- | | GET | `/jwks` (at `jwks_path`) | | GET | `/token` | ## Schema | Table | Columns | | --- | --- | | `jwks` | `id`, `publicKey`, `privateKey`, `createdAt`, `expiresAt` | ## Notes - Storage parity: the `privateKey` codec is byte-compatible with TS — a `jwks` row written here is readable by a TS app sharing the database and vice versa. `alg`/`crv` are not persisted (TS declares no such columns); they are reconstructed from `key_pair_config` on read. - Required by [OAuth Provider](./oauth-provider) unless that plugin is configured with `disable_jwt_plugin=True`. --- url: https://better-auth-py.oumarbarry.tech/plugins/bearer --- # Bearer Token Echoes the session token back on a `set-auth-token` response header so cookieless clients can store it. Mirrors the TS `bearer()` plugin — with one difference: reading `Authorization: Bearer ...` on requests is already built into this port's core session layer, so the plugin is only needed for the response side (and for `require_signature`). ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import BearerPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[BearerPlugin()], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `require_signature` | `bool` | `False` | Only accept signed tokens in the `Authorization` header; raw session tokens are stripped before core sees them. | ## Endpoints None — the plugin is request/response hooks only. It also merges `set-auth-token` into `Access-Control-Expose-Headers` so CORS clients can read it. ## Notes - Pair with [OAuth Popup](./oauth-popup) so the popup page can hand the token back to the opener. - See the [getting started guide](/guide/getting-started) for when you need this at all — pure cookie clients don't. --- url: https://better-auth-py.oumarbarry.tech/plugins/one-time-token --- # One-Time Token Mints a short-lived, single-use token from an existing session and exchanges it back for that session — the standard cross-domain or SSR handoff. Mirrors the TS `oneTimeToken()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import OneTimeTokenPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[OneTimeTokenPlugin(expires_in=3)], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `expires_in` | `int` | `3` | Token lifetime in **minutes**. | | `disable_client_request` | `bool` | `False` | Reject `/one-time-token/generate` over HTTP. | | `generate_token` | `callable \| None` | `None` | Custom token generator, `(session, ctx) -> str`. | | `disable_set_session_cookie` | `bool` | `False` | Don't set the session cookie on verify (return the session JSON only). | | `store_token` | `str \| dict` | `"plain"` | `"plain"`, `"hashed"`, or a custom-hasher config. | | `set_ott_header_on_new_session` | `bool` | `False` | Attach a one-time token header whenever a new session is created. | ## Endpoints | Method | Path | | --- | --- | | GET | `/one-time-token/generate` | | POST | `/one-time-token/verify` | ## Schema No extra tables — tokens live in the core `verification` table. ## Notes - Verification consumes the token atomically; expired tokens are rejected before any cookie is queued (TS checks expiry after queueing — a TS-side quirk this port deliberately does not reproduce). - This port is HTTP-only, so `disable_client_request=True` rejects the generate endpoint outright — there is no separate `auth.api` server-call surface. TS exports no error codes for this plugin; the error bodies here carry the generic `BAD_REQUEST` code with TS's exact message text. --- url: https://better-auth-py.oumarbarry.tech/plugins/oauth-provider --- # OAuth Provider Turns your app into an OAuth 2.1 / OIDC authorization server: client registration and management, authorize and consent, every token grant, introspection, userinfo, revocation and end-session — RFC 6749, 7009, 7636 and 7662. Mirrors the TS `@better-auth/oauth-provider` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import JWTPlugin, OAuthProviderPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[ JWTPlugin(), OAuthProviderPlugin(login_page="/login", consent_page="/consent"), ], ) ``` `JWTPlugin` is required alongside it — without it, initialization raises `ValueError: oauth-provider requires the jwt plugin to be installed`. The alternative is `disable_jwt_plugin=True`, which HS256-signs id tokens with each client's secret and stores client secrets encrypted (recoverable) instead of hashed. ## Options The most-used options (the full TS option surface is ported; all snake_case): | Option | Type | Default | Description | | --- | --- | --- | --- | | `scopes` | `list[str] \| None` | `None` | Supported scopes advertised in discovery. | | `code_expires_in` | `int` | `600` | Authorization-code lifetime (seconds). | | `access_token_expires_in` | `int` | `3600` | Access-token lifetime. | | `m2m_access_token_expires_in` | `int` | `3600` | Client-credentials token lifetime. | | `id_token_expires_in` | `int` | `36000` | Id-token lifetime. | | `refresh_token_expires_in` | `int` | `2592000` | Refresh-token lifetime (30 days). | | `allow_dynamic_client_registration` | `bool` | `False` | Enable RFC 7591 `/oauth2/register`. | | `allow_unauthenticated_client_registration` | `bool` | `False` | Registration without a session. | | `grant_types` | `list[str] \| None` | `None` | Restrict the enabled grants. | | `login_page` | `str \| None` | `None` | Where an unauthenticated `/oauth2/authorize` redirects. | | `consent_page` | `str \| None` | `None` | Where consent is collected. | | `store_tokens` | `str \| dict` | `"hashed"` | How access/refresh tokens are stored. | | `store_client_secret` | `str \| dict \| None` | `None` (hashed with jwt; encrypted without) | How client secrets are stored. | | `valid_audiences` | `list[str] \| None` | `None` | Accepted `aud` values on introspection. | | `scope_expirations` | `dict[str, int] \| None` | `None` | Per-scope token lifetimes. | | `trusted clients / claims / generators` | various | `None` | `cached_trusted_clients`, `custom_id_token_claims`, `custom_access_token_claims`, `custom_user_info_claims`, `custom_token_response_fields`, `generate_client_id`, `generate_client_secret`, `generate_refresh_token`, `generate_opaque_access_token`, `prefix`, `pairwise_secret`, `client_reference`, `client_privileges`, `request_uri_resolver`, `signup`, `select_account`, `post_login`, `format_refresh_token`, `client_registration_*`, `allow_public_client_prelogin`, `disable_jwt_plugin`, `silence_warnings`, `rate_limit`. | ## Endpoints 22 routes under `/oauth2/`: | Method | Path | | --- | --- | | POST | `/oauth2/register` | | POST | `/oauth2/create-client` | | GET | `/oauth2/get-client` | | GET | `/oauth2/public-client` | | POST | `/oauth2/public-client-prelogin` | | GET | `/oauth2/get-clients` | | POST | `/oauth2/update-client` | | POST | `/oauth2/client/rotate-secret` | | POST | `/oauth2/delete-client` | | GET | `/oauth2/authorize` | | POST | `/oauth2/token` | | POST | `/oauth2/introspect` | | POST | `/oauth2/revoke` | | GET/POST | `/oauth2/userinfo` | | GET | `/oauth2/end-session` | | POST | `/oauth2/consent` | | POST | `/oauth2/continue` | | GET | `/oauth2/get-consent` | | GET | `/oauth2/get-consents` | | POST | `/oauth2/update-consent` | | POST | `/oauth2/delete-consent` | Discovery documents (`/.well-known/...`) are served through the plugin's request hooks. ## Schema | Table | Key columns | | --- | --- | | `oauthClient` | `clientId`, `clientSecret`, `disabled`, `skipConsent`, `enableEndSession`, `subjectType`, `scopes`, `userId`, `redirectUris`, `postLogoutRedirectUris`, `tokenEndpointAuthMethod`, `grantTypes`, `responseTypes`, `public`, `type`, `requirePKCE`, `referenceId`, `metadata`, registration metadata (`name`, `uri`, `icon`, `contacts`, `tos`, `policy`, `softwareId`, `softwareVersion`, `softwareStatement`), `createdAt`, `updatedAt` | | `oauthConsent` | `clientId`, `userId`, `referenceId`, `scopes`, `createdAt`, `updatedAt` | | `oauthAccessToken` | `token`, `clientId`, `sessionId`, `userId`, `referenceId`, `refreshId`, `expiresAt`, `createdAt`, `scopes` | | `oauthRefreshToken` | `token`, `clientId`, `sessionId`, `userId`, `referenceId`, `expiresAt`, `createdAt`, `revoked`, `authTime`, `scopes` | ## Notes - For the "enter this code on your TV" flow, add [Device Authorization](./device-authorization). - To *consume* someone else's OAuth server instead of being one, see [Generic OAuth](./generic-oauth) or the [providers](/providers/) registry. --- url: https://better-auth-py.oumarbarry.tech/plugins/device-authorization --- # Device Authorization The OAuth 2.0 Device Authorization Grant (RFC 8628) — the "enter this code on another device" flow for TVs and CLIs. Mirrors the TS `deviceAuthorization()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import DeviceAuthorizationPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[DeviceAuthorizationPlugin(expires_in="30m", interval="5s")], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `expires_in` | `str` | `"30m"` | Device/user-code lifetime (duration string). | | `interval` | `str` | `"5s"` | Minimum polling interval (duration string). | | `device_code_length` | `int` | `40` | Length of the device code. | | `user_code_length` | `int` | `8` | Length of the user-facing code. | | `generate_device_code` | `callable \| None` | `None` | Custom device-code generator. | | `generate_user_code` | `callable \| None` | `None` | Custom user-code generator. | | `validate_client` | `callable \| None` | `None` | `(client_id) -> bool` gate on `/device/code`. | | `on_device_auth_request` | `callable \| None` | `None` | Observer called when a device requests a code. | | `verification_uri` | `str \| None` | `None` | Override the advertised verification URI. | ## Endpoints | Method | Path | | --- | --- | | POST | `/device/code` | | POST | `/device/token` | | GET | `/device` | | POST | `/device/approve` | | POST | `/device/deny` | ## Schema | Table | Columns | | --- | --- | | `deviceCode` | `deviceCode`, `userCode`, `userId`, `expiresAt`, `status`, `lastPolledAt`, `pollingInterval`, `clientId`, `scope` | ## Notes - Errors are OAuth-shaped on the wire (`{"error", "error_description"}`, RFC 6749 style), not this port's usual `{"code", "message"}` envelope — matching TS. - Redemption of an approved code is atomic (delete-and-return): concurrent pollers race on the same delete and exactly one mints a session. The pending claim and polling-interval bump use a guarded compare-and-swap, closing the race behind TS's GHSA-cq3f-vc6p-68fh fix. - Pairs naturally with [OAuth Provider](./oauth-provider) when you are the authorization server. --- url: https://better-auth-py.oumarbarry.tech/plugins/sso --- # SSO (OIDC) OIDC federation: register external identity providers per domain or organization and route `/sign-in/sso` to the right one, with SSRF-guarded discovery, optional DNS TXT domain verification and user/organization provisioning. Mirrors the OIDC half of the TS `@better-auth/sso` plugin — SAML is out of scope in this port. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import SSOPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[SSOPlugin(trust_email_verified=False)], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `providers_limit` | `int \| callable \| None` | `None` | Max providers a user may register. | | `default_override_user_info` | `bool` | `False` | Overwrite user fields from the IdP on every login by default. | | `default_sso` | `list[dict] \| None` | `None` | Statically configured providers (no DB row). | | `domain_verification` | `dict \| None` | `None` | Enable DNS TXT domain verification (needs the `sso` extra: `dnspython`). | | `redirect_uri` | `str \| None` | `None` | Override the callback URL registered with IdPs. | | `model_name` | `str \| None` | `None` (`"ssoProvider"`) | Table name override. | | `fields` | `dict[str, str] \| None` | `None` | Column-name overrides. | | `provision_user` | `callable \| None` | `None` | `(payload) -> None`, runs when a user is provisioned. | | `provision_user_on_every_login` | `bool` | `False` | Re-run provisioning on every login. | | `organization_provisioning` | `dict \| None` | `None` | Auto-assign users to an organization on SSO login. | | `trust_email_verified` | `bool` | `False` | Trust the IdP's `email_verified` claim. | | `disable_implicit_sign_up` | `bool` | `False` | Never create users implicitly on SSO sign-in. | | `resolve_host` / `dns_resolver` | `callable \| None` | `None` | Test seams for the SSRF guard and DNS lookups. | ## Endpoints | Method | Path | | --- | --- | | POST | `/sign-in/sso` | | GET | `/sso/callback/{providerId}` | | GET | `/sso/callback` | | POST | `/sso/register` | | GET | `/sso/providers` | | GET | `/sso/get-provider` | | POST | `/sso/update-provider` | | POST | `/sso/delete-provider` | ## Schema | Table | Columns | | --- | --- | | `ssoProvider` | `issuer`, `oidcConfig`, `samlConfig`, `userId`, `providerId`, `organizationId`, `domain` (+ `domainVerified` when `domain_verification` is enabled) | ## Notes - `samlConfig` is retained as a nullable column for cross-runtime DB compatibility only; a `providerType: "saml"` registration body is rejected. - `clientSecret` is stored in the `oidcConfig` JSON in plaintext — a deliberate cross-runtime contract (the secret is needed cleartext at every token exchange); it is masked on read. - For a single hand-configured OAuth2/OIDC provider without per-domain routing, [Generic OAuth](./generic-oauth) is the lighter tool. --- url: https://better-auth-py.oumarbarry.tech/plugins/generic-oauth --- # Generic OAuth Sign in with any OAuth2/OIDC provider that is not in the built-in registry, configured at runtime — point it at a discovery URL or spell out the endpoints. Mirrors the TS `genericOAuth()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import GenericOAuthPlugin from better_auth.plugins_ext.generic_oauth import GenericOAuthConfig auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[ GenericOAuthPlugin( config=[ GenericOAuthConfig( provider_id="keycloak", client_id="my-client", client_secret="my-secret", discovery_url="https://sso.example.com/realms/main/.well-known/openid-configuration", scopes=["openid", "email", "profile"], pkce=True, ) ] ) ], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `config` | `list[GenericOAuthConfig]` | required | One entry per provider. | Each `GenericOAuthConfig` is a dataclass mirroring the TS per-provider config: `provider_id`, `client_id`, `client_secret`, and either `discovery_url` or explicit `authorization_url` / `token_url` / `user_info_url`, plus `scopes`, `pkce`, `redirect_uri`, `response_type`, `prompt`, `access_type`, `authorization_url_params`, `token_url_params`, `map_profile_to_user`, `get_user_info`, `disable_implicit_sign_up`, `disable_sign_up`, `authentication` (`"basic"` or `"post"`), `override_user_info` and friends — check the dataclass for the full field list. ## Endpoints | Method | Path | | --- | --- | | POST | `/sign-in/oauth2` | | GET/POST | `/oauth2/callback/{providerId}` | | POST | `/oauth2/link` | ## Notes - Configured providers are also registered into `auth.social_providers`, so they ride the core social machinery (e.g. `/refresh-token`). - Implementation notes (all matching TS behavior): the `id_token` from the token exchange is decoded without signature verification (it arrived over TLS from the token endpoint); the discovery document is re-fetched per endpoint call (no stale cache); `sign-in/oauth2` does not origin-check `callbackURL`. - Only the discovery-based provider presets are ported; presets requiring bespoke fetch logic are not. - For providers in the built-in registry, configure them directly — see [providers](/providers/). --- url: https://better-auth-py.oumarbarry.tech/plugins/oauth-proxy --- # OAuth Proxy Lets preview and branch deployments finish a social login against the single redirect URI registered with the provider, by proxying the callback through the fixed production deployment. Mirrors the TS `oAuthProxy()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import OAuthProxyPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[OAuthProxyPlugin(production_url="https://example.com")], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `current_url` | `str \| None` | `None` (request origin, then vendor env URL, then `base_url`) | The current deployment URL; trusted as-is. | | `production_url` | `str \| None` | `None` (`BETTER_AUTH_URL` / `base_url`) | The fixed production URL; requests already on this origin are not proxied. | | `max_age` | `int` | `60` | Max age (seconds) of an encrypted profile before it is rejected as a replay. | | `secret` | `str \| None` | `None` (`auth.secret`) | Dedicated proxy secret, used instead of `auth.secret` for all proxy encryption; must be shared across every environment in the flow. | ## Endpoints | Method | Path | | --- | --- | | GET | `/oauth-proxy-callback` | The rest of the plugin is request hooks around `/sign-in/social`, `/sign-in/oauth2` and `/callback/{provider}`. ## Notes - Production runs the code→token→userinfo exchange, encrypts the resulting profile under the shared secret, and 302s it back to the preview's `/oauth-proxy-callback`, which creates the user and session locally — the preview and production do not need to share `BETTER_AUTH_SECRET` (set `secret` if they don't). - Deploy checklist: see [production deployment](/deploy/production). - Works with both registry [providers](/providers/) and [Generic OAuth](./generic-oauth). --- url: https://better-auth-py.oumarbarry.tech/plugins/oauth-popup --- # OAuth Popup Runs social sign-in in a popup window: the client navigates the popup to `/oauth-popup/start`, and on the OAuth callback the plugin swaps the redirect for a page that posts the session token (or error) back to the opener. Mirrors the TS `oauthPopup()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import BearerPlugin, OAuthPopupPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[OAuthPopupPlugin(), BearerPlugin()], ) ``` ## Options None — the plugin takes no options (same as TS). ## Endpoints | Method | Path | | --- | --- | | GET | `/oauth-popup/start` | ## Notes - Pair it with [Bearer Token](./bearer) so the opener can use the posted token. - The completion page's inline script is byte-identical to TS and its sha256 is pinned in the response CSP. - Implementation note: state is stored as a verification row plus a signed CSRF cookie (this port's OAuth-state convention), so the normal `/callback` and `/oauth2/callback` routes consume it unchanged; `additionalData` is nested under its own key with internal state keys stripped. --- url: https://better-auth-py.oumarbarry.tech/plugins/multi-session --- # Multi-Session Several accounts signed in at once, each with its own device-session cookie, plus endpoints to list, switch and revoke them. Mirrors the TS `multiSession()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import MultiSessionPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[MultiSessionPlugin(maximum_sessions=5)], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `maximum_sessions` | `int` | `5` | Cap on concurrent device-session cookies. Once exceeded, a fresh sign-in still gets its main session cookie but no additional per-device slot (silently, as in TS). | ## Endpoints | Method | Path | | --- | --- | | GET | `/multi-session/list-device-sessions` | | POST | `/multi-session/set-active` | | POST | `/multi-session/revoke` | ## Notes - Cookie scheme (TS parity): one additional signed cookie per device session, named `_multi-`. - `set-active` and `revoke` act on the token proven by the signed cookie value, never on the request-body value itself — a request cannot pair a validly-signed cookie with an unrelated token. - [Custom Session](./custom-session) can opt into reshaping `list-device-sessions` via `should_mutate_list_device_sessions_endpoint`. --- url: https://better-auth-py.oumarbarry.tech/plugins/custom-session --- # Custom Session Wraps `GET /get-session` so you can reshape or enrich what clients receive — joining a subscription, a role, a tenant. Mirrors the TS `customSession()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import CustomSessionPlugin async def with_plan(session, ctx): return {**session, "plan": "pro"} auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[CustomSessionPlugin(with_plan)], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `fn` | `callable` | required (positional) | `(session, ctx) -> dict`, returns the payload clients receive. | | `should_mutate_list_device_sessions_endpoint` | `bool` | `False` | Also apply `fn` to each entry of [Multi-Session](./multi-session)'s `list-device-sessions`. | ## Endpoints Overrides the existing route: | Method | Path | | --- | --- | | GET | `/get-session` | ## Notes - Deliberate simplification: TS's `fn` also receives a third `options` argument used only for type inference; the Python callable takes `(session, ctx)`. --- url: https://better-auth-py.oumarbarry.tech/plugins/last-login-method --- # Last Login Method Records which method was used on the most recent successful sign-in, in a cookie and optionally in the database — the "you last signed in with GitHub" hint. Mirrors the TS `lastLoginMethod()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import LastLoginMethodPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[LastLoginMethodPlugin(store_in_database=True)], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `cookie_name` | `str` | `"better-auth.last_used_login_method"` | Cookie carrying the hint. | | `max_age` | `int` | `2592000` | Cookie lifetime in seconds (30 days). | | `custom_resolve_method` | `callable \| None` | `None` | `(ctx) -> str \| None`, override how the method name is derived. | | `store_in_database` | `bool` | `False` | Also persist on the `user` row. | | `before_store_cookie` | `callable \| None` | `None` | `(ctx, method) -> bool`, gate the cookie (consent). | | `schema` | `dict \| None` | `None` | Field-name override for the database column. | ## Endpoints None — the plugin is hooks only. ## Schema Only when `store_in_database=True`: | Table | Added columns | | --- | --- | | `user` | `lastLoginMethod` | ## Notes - The default resolver derives the method from the sign-in path (e.g. `email`, a social provider id, `siwe`); `custom_resolve_method` replaces it. --- url: https://better-auth-py.oumarbarry.tech/plugins/captcha --- # Captcha Verifies an `x-captcha-response` header against a CAPTCHA provider before the protected endpoints run. Supports Cloudflare Turnstile, Google reCAPTCHA, hCaptcha and CaptchaFox. Mirrors the TS `captcha()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import CaptchaPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[ CaptchaPlugin(provider="cloudflare-turnstile", secret_key="your-secret-key") ], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `provider` | `str` | required | `"cloudflare-turnstile"`, `"google-recaptcha"`, `"hcaptcha"` or `"captchafox"`. | | `secret_key` | `str` | required | The provider's siteverify secret. | | `endpoints` | `list[str] \| None` | `None` (`["/sign-up/email", "/sign-in/email", "/request-password-reset"]`) | Paths to protect. `/sign-in/email-otp` is exempt unless named explicitly. | | `site_verify_url_override` | `str \| None` | `None` | Alternate siteverify endpoint. | | `min_score` | `float` | `0.5` | Minimum score (score-based providers, e.g. reCAPTCHA v3). | | `expected_action` | `str \| None` | `None` | Expected action claim. | | `allowed_hostnames` | `list[str] \| None` | `None` | Accepted hostnames in the provider response. | | `site_key` | `str \| None` | `None` | Site key (providers that verify it server-side). | ## Endpoints None added — the plugin runs in `on_request`, after core rate limiting and before route dispatch, so a rejected captcha never reaches the endpoint handler. ## Notes - Fails closed: any non-2xx, transport error or malformed body from the provider's siteverify endpoint is a 500, never a pass. - Flattened option set: only the fields relevant to the configured `provider` are read (the TS options are a per-provider union). --- url: https://better-auth-py.oumarbarry.tech/plugins/have-i-been-pwned --- # Have I Been Pwned Rejects passwords found in the Have I Been Pwned breach corpus, using a k-anonymity range query so the password never leaves your server. Runs before hashing on every configured password path. Mirrors the TS `haveIBeenPwned()` plugin. ## Enable ```python from better_auth import BetterAuth from better_auth.plugins_ext import HaveIBeenPwnedPlugin auth = BetterAuth( secret="a-strong-32-character-minimum-secret", plugins=[HaveIBeenPwnedPlugin()], ) ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `custom_password_compromised_message` | `str \| None` | `None` | Message returned when a password is found in a breach. | | `paths` | `list[str] \| None` | `None` | Paths to check. Default: `/sign-up/email`, `/change-password`, `/reset-password`, `/email-otp/reset-password`, `/phone-number/reset-password`, `/admin/create-user`, `/admin/set-user-password`. | | `enabled` | `bool` | `True` | Turn the check off without removing the plugin. | ## Endpoints None — the plugin registers a password check run by `hash_password_checked` before every password hash on the configured paths. ## Notes - Only the first five characters of the SHA-1 hash are sent to the HIBP range API; the match is done locally. - Plugin-owned paths in the default list only take effect when the matching plugin (e.g. [Admin](./admin), [Email OTP](./email-otp), [Phone Number](./phone-number)) is installed — the check is keyed on the request path. --- url: https://better-auth-py.oumarbarry.tech/providers/ --- # Social providers 35 OAuth2/OIDC providers are built in. Every one gets PKCE where the provider supports it, single-use database-backed state with a signed state cookie, token refresh, and JWKS or id-token verification where the provider is OIDC. ## Configuring Two equivalent forms. By instance: ```python from better_auth import BetterAuth, GitHub, Google auth = BetterAuth( secret=..., social_providers={ "github": GitHub(client_id="…", client_secret="…"), "google": Google(client_id="…", client_secret="…"), }, ) ``` Or name-keyed, resolved against `PROVIDER_REGISTRY`: ```python auth = BetterAuth( secret=..., social_providers={ "gitlab": {"client_id": "…", "client_secret": "…"}, "slack": {"client_id": "…", "client_secret": "…"}, }, ) ``` ::: tip Import path `GitHub`, `Google` and `Discord` are re-exported at the package root. The other 32 classes live in `better_auth.oauth.providers_ext`: ```python from better_auth.oauth.providers_ext import Apple, MicrosoftEntraId, Slack ``` The name-keyed form needs no import at all. ::: ## The flow ```bash curl -s -X POST localhost:8000/api/auth/sign-in/social \ -H 'content-type: application/json' \ -d '{"provider": "github", "callbackURL": "/dashboard"}' ``` ```json { "url": "https://github.com/login/oauth/authorize?…", "redirect": true } ``` Send the browser to `url`. The provider comes back to `{base_url}/api/auth/callback/{provider}`, which sets the session cookie and redirects to `callbackURL`. Every `callbackURL` is validated against `base_url` and `trusted_origins`, so it cannot be turned into an open redirect. The redirect URI you register with the provider is `{base_url}{base_path}/callback/{provider_id}` — for example `https://example.com/api/auth/callback/github`. Override it with `redirect_uri=` when the provider insists on something else. ## The 35 providers One page per provider — endpoints, real dataclass options, default scopes, and per-provider quirks: - [Apple](/providers/apple) - [Atlassian](/providers/atlassian) - [Amazon Cognito](/providers/cognito) - [Discord](/providers/discord) - [Dropbox](/providers/dropbox) - [Facebook](/providers/facebook) - [Figma](/providers/figma) - [GitHub](/providers/github) - [GitLab](/providers/gitlab) - [Google](/providers/google) - [Hugging Face](/providers/huggingface) - [Kakao](/providers/kakao) - [Kick](/providers/kick) - [LINE](/providers/line) - [Linear](/providers/linear) - [LinkedIn](/providers/linkedin) - [Microsoft Entra ID](/providers/microsoft) - [Naver](/providers/naver) - [Notion](/providers/notion) - [Paybin](/providers/paybin) - [PayPal](/providers/paypal) - [Polar](/providers/polar) - [Railway](/providers/railway) - [Reddit](/providers/reddit) - [Roblox](/providers/roblox) - [Salesforce](/providers/salesforce) - [Slack](/providers/slack) - [Spotify](/providers/spotify) - [TikTok](/providers/tiktok) - [Twitch](/providers/twitch) - [Twitter (X)](/providers/twitter) - [Vercel](/providers/vercel) - [VK](/providers/vk) - [WeChat](/providers/wechat) - [Zoom](/providers/zoom) The link slug is the registry key — the name you use in `social_providers` and in the callback path. `better_auth.oauth.PROVIDER_REGISTRY` is the same map at runtime: ```python from better_auth.oauth import PROVIDER_REGISTRY len(PROVIDER_REGISTRY) # 35 ``` ## Per-provider options Every provider inherits the same option surface (`ProviderConfig`): ```python from better_auth import GitHub GitHub( client_id="…", client_secret="…", scopes=["read:user", "user:email"], redirect_uri=None, # overrides {base_url}{base_path}/callback/{id} authorize_params={"prompt": "consent"}, # extra authorize-URL params disable_default_scope=False, # drop the baked-in scopes first disable_sign_up=False, # never create a user via this provider disable_implicit_sign_up=False, # require requestSignUp:true to register override_user_info_on_sign_in=False, # re-sync the profile on every sign-in authentication="post", # or "basic" for the token endpoint ) ``` ## A custom provider Anything not in the registry is one dataclass: ```python from better_auth import OAuthProvider okta = OAuthProvider( client_id="…", client_secret="…", provider_id="okta", authorization_endpoint="https://your-org.okta.com/oauth2/v1/authorize", token_endpoint="https://your-org.okta.com/oauth2/v1/token", userinfo_endpoint="https://your-org.okta.com/oauth2/v1/userinfo", scopes=["openid", "email", "profile"], use_pkce=True, ) auth = BetterAuth(secret=..., social_providers={"okta": okta}) ``` `OAuthProvider` is an alias of `ProviderConfig`. The default `fetch_user()` expects an OIDC-shaped userinfo payload (`sub`, `email`, `email_verified`, `name`, `picture`). For a provider whose payload differs, either supply a `profile_mapper`, or subclass and override `fetch_user()` — the GitHub and Discord sources are the two worked examples in the codebase. For a provider you would rather configure at runtime than as a class — from a database row, say — use the [Generic OAuth plugin](/plugins/generic-oauth), which also supports OIDC discovery URLs. ## Account linking A social sign-in whose verified email matches an existing user links to that user instead of creating a second one. This is guarded: `AccountLinking(require_local_email_verified=True)` is the default, and `trusted_providers` controls which providers may link at all. ```python from better_auth import AccountLinking, AccountOptions AccountOptions( encrypt_oauth_tokens=True, account_linking=AccountLinking( enabled=True, trusted_providers=["github", "google"], allow_different_emails=False, ), ) ``` `/link-social`, `/list-accounts`, `/unlink-account` and `/account-info` manage links for an already-signed-in user. `allow_unlinking_all=False` (the default) stops a user from removing their last credential. --- url: https://better-auth-py.oumarbarry.tech/providers/apple --- # Apple Sign in with Apple. OIDC with PKCE (S256); id tokens are verified against Apple's JWKS. User info comes from the id token — Apple has no userinfo endpoint. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Apple auth = BetterAuth( secret=..., social_providers={ "apple": Apple(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "apple": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | The Services ID. Required before the authorize URL is built (`CLIENT_ID_AND_SECRET_REQUIRED`). | | `client_secret` | `str` | required | An ES256 JWT, not a static string — see `generate_client_secret` below. Also required up front. | | `app_bundle_identifier` | `str \| None` | `None` | Native iOS id tokens carry the app bundle id as audience, not the Services ID. | | `audience` | `str \| list[str] \| None` | `None` | Explicit accepted id-token audience(s); overrides `app_bundle_identifier` and `client_id`. | | `disable_id_token_sign_in` | `bool` | `False` | Refuse direct id-token sign-in. | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `email name`. - Register `{base_url}{base_path}/callback/apple` (e.g. `https://example.com/api/auth/callback/apple`) as the return URL in the Apple developer console. Apple **POSTs** the callback: the authorize URL uses `response_type=code id_token` with `response_mode=form_post`. - Id-token verification: JWKS `https://appleid.apple.com/auth/keys`, issuer `https://appleid.apple.com`, 1-hour max token age. The nonce is accepted either raw or as `sha256hex(nonce)` — Apple's native SDKs sometimes hash it client-side. `email_verified` / `is_private_email` arrive as booleans or the strings `"true"`/`"false"` and are coerced. - `Apple.generate_client_secret(client_id=…, team_id=…, key_id=…, private_key=…)` builds the ES256 client-secret JWT from your `.p8` key (Apple rejects secrets expiring more than six months out). --- url: https://better-auth-py.oumarbarry.tech/providers/atlassian --- # Atlassian Atlassian account OAuth2 with PKCE (S256). Pure OAuth2 — no id token. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Atlassian auth = BetterAuth( secret=..., social_providers={ "atlassian": Atlassian(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "atlassian": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `read:jira-user offline_access`. - Register `{base_url}{base_path}/callback/atlassian` as the callback URL in the Atlassian developer console. - The authorize URL always carries `audience=api.atlassian.com` (default `authorize_params`). - User info is a bearer-token GET on `https://api.atlassian.com/me`; `email_verified` is always `False` — Atlassian's `/me` exposes no verification flag. --- url: https://better-auth-py.oumarbarry.tech/providers/cognito --- # Amazon Cognito Amazon Cognito user pools. OIDC with PKCE (S256) and id-token verification against the pool's JWKS. Per-pool config is required at construction. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Cognito auth = BetterAuth( secret=..., social_providers={ "cognito": Cognito( client_id="…", client_secret="…", domain="your-domain.auth.eu-west-1.amazoncognito.com", region="eu-west-1", user_pool_id="eu-west-1_XXXX", ), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "cognito": { "client_id": "…", "client_secret": "…", "domain": "your-domain.auth.eu-west-1.amazoncognito.com", "region": "eu-west-1", "user_pool_id": "eu-west-1_XXXX", }, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | | `domain` | `str` | required | Hosted-UI domain; a leading `https://` is stripped. Missing → `ValueError` at construction. | | `region` | `str` | required | AWS region of the pool. Missing → `ValueError` at construction. | | `user_pool_id` | `str` | required | Missing → `ValueError` at construction. | | `require_client_secret` | `bool` | `False` | Parity field from the TS options surface; not read by the flow. | | `disable_id_token_sign_in` | `bool` | `False` | Refuse direct id-token sign-in. | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `openid profile email`. - Register `{base_url}{base_path}/callback/cognito` as an allowed callback URL on the app client. - Endpoints derive from `domain` (`https://{domain}/oauth2/authorize|token|userinfo`); JWKS and issuer derive from `region` + `user_pool_id` (`https://cognito-idp.{region}.amazonaws.com/{user_pool_id}`). - Id tokens are verified with a 1-hour max token age. - AWS requires `%20`-encoded scopes (not `+`), so the authorize URL's query is re-encoded accordingly. - User info prefers the decoded `id_token`; the userinfo endpoint is the fallback. --- url: https://better-auth-py.oumarbarry.tech/providers/discord --- # Discord Discord OAuth2. Pure OAuth2 — no PKCE, no id token. ## Configure ```python from better_auth import BetterAuth, Discord auth = BetterAuth( secret=..., social_providers={ "discord": Discord(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "discord": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `identify email`. - Register `{base_url}{base_path}/callback/discord` as a redirect in the Discord developer portal. - `Discord` is re-exported at the package root (`from better_auth import Discord`). - Avatar mapping: users with a custom avatar get the CDN URL; otherwise the default-avatar CDN fallback is computed — `(id >> 22) % 6` for new usernames, `discriminator % 5` for legacy discriminator accounts. - The display name prefers `global_name`, falling back to `username`. --- url: https://better-auth-py.oumarbarry.tech/providers/dropbox --- # Dropbox Dropbox OAuth2 with PKCE (S256). No id token. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Dropbox auth = BetterAuth( secret=..., social_providers={ "dropbox": Dropbox(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "dropbox": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | | `access_type` | `str` | `""` | `"offline"`, `"online"` or `"legacy"` — forwarded as the Dropbox-specific `token_access_type` authorize param. Set `"offline"` to get a refresh token. | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `account_info.read`. - Register `{base_url}{base_path}/callback/dropbox` as a redirect URI in the Dropbox App Console. - User info is a **POST** to `/2/users/get_current_account` (Dropbox requires POST, not GET, with no body). --- url: https://better-auth-py.oumarbarry.tech/providers/facebook --- # Facebook Facebook Login (Graph API v24.0). OAuth2 without PKCE, plus a separate **Limited Login** path whose JWTs are verified against Facebook's dedicated JWKS. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Facebook auth = BetterAuth( secret=..., social_providers={ "facebook": Facebook(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "facebook": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | The app id. Also used (with `client_secret`) to app-bind opaque tokens via `debug_token`. | | `client_secret` | `str` | required | | | `fields` | `list[str]` | `[]` | Extra Graph profile fields appended to the `/me` request (beyond `id,name,email,picture`). | | `config_id` | `str \| None` | `None` | Facebook login configuration id — sent as the `config_id` authorize param. | | `disable_id_token_sign_in` | `bool` | `False` | Refuse direct token sign-in (both JWT and opaque paths). | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `email public_profile`. - Register `{base_url}{base_path}/callback/facebook` as a valid OAuth redirect URI in the Meta developer console. - Two token paths on direct sign-in: a 3-segment JWT is a **Limited Login** token, verified against `https://limited.facebook.com/.well-known/oauth/openid/jwks/` (issuer `https://www.facebook.com`); anything else is an opaque access token, validated through Graph `debug_token` (must be valid, bound to a configured app id, and carry a `user_id`). - The Graph `/me` endpoint is not app-bound, so the access token is app-verified via `debug_token` before its profile is trusted, and the returned profile `id` must match the token's `user_id`. - Limited-Login id tokens carry no `email_verified` claim — mapped as `False`. --- url: https://better-auth-py.oumarbarry.tech/providers/figma --- # Figma Figma OAuth2 with PKCE (S256). No id token. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Figma auth = BetterAuth( secret=..., social_providers={ "figma": Figma(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "figma": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `current_user:read`. - Register `{base_url}{base_path}/callback/figma` as the redirect URI in your Figma app settings. - Token-endpoint client auth is **basic** (`Authorization: Basic`), not the default body-post — for both code exchange and refresh. - Profile mapping: name comes from `handle`, avatar from `img_url`; `email_verified` is always `False` (Figma exposes no verification flag). --- url: https://better-auth-py.oumarbarry.tech/providers/github --- # GitHub GitHub OAuth2. Pure OAuth2 — no PKCE, no id token. ## Configure ```python from better_auth import BetterAuth, GitHub auth = BetterAuth( secret=..., social_providers={ "github": GitHub(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "github": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `read:user user:email`. - Register `{base_url}{base_path}/callback/github` as the authorization callback URL in the GitHub OAuth app settings. - `GitHub` is re-exported at the package root (`from better_auth import GitHub`). - User info takes **two** API calls: `GET /user` for the profile, then `GET /user/emails` to resolve the primary email and its `verified` flag (the profile's public email can be absent or unverified). - Display name prefers `name`, falling back to `login`. --- url: https://better-auth-py.oumarbarry.tech/providers/gitlab --- # GitLab GitLab OAuth2 with PKCE (S256), for gitlab.com or self-hosted instances. No id token. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Gitlab auth = BetterAuth( secret=..., social_providers={ "gitlab": Gitlab(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "gitlab": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | | `issuer` | `str` | `""` | Self-hosted GitLab base URL; empty means `https://gitlab.com`. All three endpoints derive from it (double slashes are collapsed, so a trailing-slash issuer is safe). | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `read_user`. - Register `{base_url}{base_path}/callback/gitlab` as the redirect URI in the GitLab application settings. - The class is `Gitlab` (lowercase `l`), matching the TS export. - Sign-in is rejected when the GitLab account `state` is not `"active"` or the account is `locked`. --- url: https://better-auth-py.oumarbarry.tech/providers/google --- # Google Google OIDC with PKCE (S256), a nonce on the authorize URL, and id-token verification against Google's JWKS. ## Configure ```python from better_auth import BetterAuth, Google auth = BetterAuth( secret=..., social_providers={ "google": Google(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "google": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | A list accepts several audiences on id-token verification (e.g. web + iOS client ids). | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply — e.g. `authorize_params={"access_type": "offline", "prompt": "consent"}` to get a refresh token. ## Notes - Default scopes: `openid email profile`. - Register `{base_url}{base_path}/callback/google` as an authorized redirect URI in the Google Cloud console. - `Google` is re-exported at the package root (`from better_auth import Google`). - OIDC: a nonce is generated, sent on the authorize URL and checked at verification. Id tokens are verified against `https://www.googleapis.com/oauth2/v3/certs` with issuers `https://accounts.google.com` and `accounts.google.com`, enabling direct id-token sign-in. --- url: https://better-auth-py.oumarbarry.tech/providers/huggingface --- # Hugging Face Hugging Face OAuth (OIDC-shaped) with PKCE (S256). Standard bearer-token userinfo; no id-token direct sign-in. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Huggingface auth = BetterAuth( secret=..., social_providers={ "huggingface": Huggingface(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "huggingface": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `openid profile email`. - Register `{base_url}{base_path}/callback/huggingface` as the redirect URL in your Hugging Face OAuth app. - Profile mapping: display name prefers `name`, falling back to `preferred_username`. --- url: https://better-auth-py.oumarbarry.tech/providers/kakao --- # Kakao Kakao Login OAuth2. No PKCE, no id token. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Kakao auth = BetterAuth( secret=..., social_providers={ "kakao": Kakao(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "kakao": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | The Kakao REST API key. | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `account_email profile_image profile_nickname`. - Register `{base_url}{base_path}/callback/kakao` as the redirect URI in the Kakao developers console. - The profile is nested under `kakao_account` (and `kakao_account.profile`): nickname, `profile_image_url`/`thumbnail_image_url`, email. - `email_verified` is the AND of Kakao's `is_email_valid` and `is_email_verified` flags. --- url: https://better-auth-py.oumarbarry.tech/providers/kick --- # Kick Kick OAuth2 with PKCE (S256). No id token. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Kick auth = BetterAuth( secret=..., social_providers={ "kick": Kick(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "kick": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `user:read`. - Register `{base_url}{base_path}/callback/kick` as the redirect URI in the Kick developer settings. - The userinfo endpoint (`https://api.kick.com/public/v1/users`) returns `{"data": [...]}` — the first entry is the profile; an empty array rejects the sign-in. - Kick never returns an `email_verified` claim — mapped as `False`. --- url: https://better-auth-py.oumarbarry.tech/providers/line --- # LINE LINE Login v2.1 with PKCE (S256). Id tokens are verified through LINE's own `/verify` endpoint — not JWKS. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Line auth = BetterAuth( secret=..., social_providers={ "line": Line(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "line": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | The LINE channel ID. | | `client_secret` | `str` | required | The channel secret. | | `disable_id_token_sign_in` | `bool` | `False` | Refuse direct id-token sign-in. | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `openid profile email`. - Register `{base_url}{base_path}/callback/line` as a callback URL on the LINE Login channel. - Id-token verification POSTs to `https://api.line.me/oauth2/v2.1/verify` and checks `aud` (must equal the channel ID) and `nonce` — there is no JWKS. - User info prefers the decoded `id_token` (no network call); the userinfo endpoint is the fallback. - LINE exposes no email-verification flag — `email_verified` is always `False`. --- url: https://better-auth-py.oumarbarry.tech/providers/linear --- # Linear Linear OAuth2. No PKCE, no id token. User info comes from Linear's GraphQL API. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Linear auth = BetterAuth( secret=..., social_providers={ "linear": Linear(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "linear": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `read`. - Register `{base_url}{base_path}/callback/linear` as the callback URL in the Linear OAuth application. - User info is a GraphQL query — `POST https://api.linear.app/graphql` with a `viewer { id name email avatarUrl … }` query, not a REST GET. A response without `viewer` rejects the sign-in. - Linear never returns an `email_verified` claim — mapped as `False`. --- url: https://better-auth-py.oumarbarry.tech/providers/linkedin --- # LinkedIn LinkedIn OIDC sign-in. No PKCE; standard bearer-token userinfo, no id-token direct sign-in. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import LinkedIn auth = BetterAuth( secret=..., social_providers={ "linkedin": LinkedIn(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "linkedin": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `profile email openid`. - Register `{base_url}{base_path}/callback/linkedin` as an authorized redirect URL in the LinkedIn developer portal. - Userinfo is the OIDC endpoint `https://api.linkedin.com/v2/userinfo`; `email_verified` defaults to `False` when LinkedIn omits the claim. --- url: https://better-auth-py.oumarbarry.tech/providers/microsoft --- # Microsoft Entra ID Microsoft Entra ID (Azure AD), registry key `microsoft`. OIDC with PKCE (S256) and id-token verification, including hand-rolled multi-tenant issuer validation. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import MicrosoftEntraId auth = BetterAuth( secret=..., social_providers={ "microsoft": MicrosoftEntraId( client_id="…", client_secret="…", tenant_id="common" ), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "microsoft": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | `""` | Optional — public clients (SPA/native + PKCE) are supported. | | `tenant_id` | `str \| None` | `None` | Tenant segment of every endpoint; `None` means `common`. Also `organizations`, `consumers`, or a specific tenant id. | | `authority` | `str \| None` | `None` | Base authority URL; `None` means `https://login.microsoftonline.com` (trailing slashes trimmed). | | `profile_photo_size` | `int` | `48` | Pixel size of the Microsoft Graph photo fetch. | | `disable_profile_photo` | `bool` | `False` | Skip the Graph photo fetch. | | `prompt` | `str \| None` | `None` | Forwarded as the `prompt` authorize param. | | `disable_id_token_sign_in` | `bool` | `False` | Refuse direct id-token sign-in. | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `openid profile email User.Read offline_access`. - Register `{base_url}{base_path}/callback/microsoft` as a redirect URI on the app registration. - Endpoints are `{authority}/{tenant}/oauth2/v2.0/authorize|token` with JWKS at `{authority}/{tenant}/discovery/v2.0/keys`. - Multi-tenant id-token verification: for `common`/`organizations`/`consumers` there is no single expected `iss`, so the token's `tid` claim is cross-checked against its `iss` (`{authority}/{tid}/v2.0`); `organizations` rejects consumer-tenant tokens, `consumers` requires them. Max token age is 1 hour, and the nonce is checked when present. - The profile photo is fetched from Microsoft Graph (`/me/photos/{size}x{size}/$value`) and inlined as a `data:` URI; a photo failure never blocks sign-in. - `email_verified` falls back to membership in `verified_primary_email`/`verified_secondary_email` when the optional claim is absent. --- url: https://better-auth-py.oumarbarry.tech/providers/naver --- # Naver Naver Login OAuth2. No PKCE, no id token. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Naver auth = BetterAuth( secret=..., social_providers={ "naver": Naver(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "naver": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `profile email`. - Register `{base_url}{base_path}/callback/naver` as the callback URL in the Naver developers console. - The userinfo payload is wrapped in a `{resultcode, message, response}` envelope; sign-in is rejected unless `resultcode == "00"`, then the nested `response` object is mapped. - `email_verified` is always `False` — Naver exposes no verification flag. --- url: https://better-auth-py.oumarbarry.tech/providers/notion --- # Notion Notion public-integration OAuth2. No PKCE, no id token, and no OAuth scopes — permissions live in the integration's capabilities. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Notion auth = BetterAuth( secret=..., social_providers={ "notion": Notion(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "notion": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: none (Notion's permission model is the integration's capabilities, not OAuth scopes). - Register `{base_url}{base_path}/callback/notion` as the redirect URI on the public integration. - `owner=user` is always sent on the authorize URL (default `authorize_params`). - Token-endpoint client auth is **basic** (RFC 7617) — Notion rejects the body-post form. - Userinfo is `GET /v1/users/me` with a `Notion-Version: 2022-06-28` header; the actual user profile is nested at `bot.owner.user`. `email` can be absent, and `email_verified` is always `False`. --- url: https://better-auth-py.oumarbarry.tech/providers/paybin --- # Paybin Paybin identity provider (OIDC-shaped) with **required** PKCE (S256). User info comes from the decoded id token — no userinfo call. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Paybin auth = BetterAuth( secret=..., social_providers={ "paybin": Paybin(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "paybin": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | Checked before the authorize URL is built (`CLIENT_ID_AND_SECRET_REQUIRED`). | | `client_secret` | `str` | required | Also checked up front. | | `issuer` | `str` | `"https://idp.paybin.io"` | Authorize/token endpoints derive from it (`{issuer}/oauth2/authorize|token`) unless explicitly overridden. | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `openid email profile`. - Register `{base_url}{base_path}/callback/paybin` as the redirect URI with Paybin. - PKCE is mandatory — building the authorize URL without a code verifier raises (matching TS). - User info is the decoded (unverified, per TS `decodeJwt`) `id_token`; there is no JWKS, so direct id-token sign-in is not available. - Display name prefers `name`, falling back to `preferred_username`. --- url: https://better-auth-py.oumarbarry.tech/providers/paypal --- # PayPal PayPal "Log in with PayPal". OIDC with PKCE, dual-algorithm id-token verification (RS256 via JWKS or HS256 via the client secret), and environment-selected endpoints. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Paypal auth = BetterAuth( secret=..., social_providers={ "paypal": Paypal(client_id="…", client_secret="…", environment="live"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "paypal": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | Checked before the authorize URL is built (`CLIENT_ID_AND_SECRET_REQUIRED`). | | `client_secret` | `str` | required | Also checked up front; doubles as the HS256 verification key. | | `environment` | `str` | `"sandbox"` | `"sandbox"` or `"live"` — selects every endpoint host (authorize, token, userinfo, issuer, JWKS). | | `prompt` | `str \| None` | `None` | Forwarded as the `prompt` authorize param. | | `request_shipping_address` | `bool` | `False` | Parity field from the TS options surface; not read by the flow. | | `disable_id_token_sign_in` | `bool` | `False` | Refuse direct id-token sign-in. | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: none — permissions are configured in the PayPal dashboard, so the authorize URL carries an empty `scope` param. - Register `{base_url}{base_path}/callback/paypal` as the return URL on the PayPal app. **The default environment is `sandbox`** — set `environment="live"` for production. - Token exchange and refresh are hand-rolled: HTTP Basic auth plus `accept-language: en_US`, and the exchange body deliberately omits `code_verifier` (PKCE is only on the authorize URL, matching TS). - Id-token verification accepts `RS256` (published JWKS) or `HS256` (raw `client_secret` as HMAC key); any other algorithm is rejected. Issuer/audience checked, 1-hour max token age, nonce checked when present. - Userinfo (`?schema=paypalv1.1`) is bound to the id token: its `sub`/`user_id` must match the id token's `sub`, else the sign-in is rejected. --- url: https://better-auth-py.oumarbarry.tech/providers/polar --- # Polar Polar OAuth2 (OIDC-shaped) with PKCE (S256). No id-token direct sign-in. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Polar auth = BetterAuth( secret=..., social_providers={ "polar": Polar(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "polar": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply — TS's `prompt` option maps to `authorize_params={"prompt": "…"}`. ## Notes - Default scopes: `openid profile email`. - Register `{base_url}{base_path}/callback/polar` as the redirect URI on the Polar OAuth client. - Profile mapping: id from `id`, name prefers `public_name` then `username`, avatar from `avatar_url`; `email_verified` defaults to `False` when Polar omits it. --- url: https://better-auth-py.oumarbarry.tech/providers/railway --- # Railway Railway OAuth2 (OIDC-shaped) with PKCE (S256) and basic token-endpoint auth. No id-token direct sign-in. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Railway auth = BetterAuth( secret=..., social_providers={ "railway": Railway(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "railway": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `openid email profile`. - Register `{base_url}{base_path}/callback/railway` as the redirect URI on the Railway OAuth app. - Token-endpoint client auth is **basic** (`Authorization: Basic`) for both exchange and refresh. - Railway's userinfo never returns an `email_verified` claim — always mapped as `False` (TS: "default to false for security consistency"). --- url: https://better-auth-py.oumarbarry.tech/providers/reddit --- # Reddit Reddit OAuth2. No PKCE, no id token; basic token-endpoint auth and a mandatory custom `User-Agent`. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Reddit auth = BetterAuth( secret=..., social_providers={ "reddit": Reddit(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "reddit": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply — Reddit's `duration` authorize param (refresh-token issuance) is `authorize_params={"duration": "permanent"}`. ## Notes - Default scopes: `identity`. - Register `{base_url}{base_path}/callback/reddit` as the redirect URI in the Reddit app preferences. - Reddit blocks generic HTTP clients: the token exchange sends `accept: text/plain` and a non-default `User-Agent` (`better-auth-py`); userinfo (`GET /api/v1/me`) carries the same `User-Agent`. - The `identity` scope never returns an email, so a stable non-routable placeholder is synthesized — `{id}@reddit.invalid` (RFC 2606) — always unverified. Avatar URLs are stripped of their query string. --- url: https://better-auth-py.oumarbarry.tech/providers/roblox --- # Roblox Roblox OAuth2 (OIDC-shaped userinfo). No PKCE, no id-token direct sign-in. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Roblox auth = BetterAuth( secret=..., social_providers={ "roblox": Roblox(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "roblox": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `openid profile`. - Register `{base_url}{base_path}/callback/roblox` as the redirect URL on the Roblox OAuth app. - The authorize URL carries `prompt=select_account consent` by default (default `authorize_params`). - Roblox never returns an email: `email` is filled with `preferred_username` as a placeholder and `email_verified` is always `False` (matching TS). Display name prefers `nickname`. --- url: https://better-auth-py.oumarbarry.tech/providers/salesforce --- # Salesforce Salesforce OAuth2 with PKCE (S256), for production, sandbox, or a My Domain host. No id-token direct sign-in. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Salesforce auth = BetterAuth( secret=..., social_providers={ "salesforce": Salesforce(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "salesforce": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | The connected app's consumer key. | | `client_secret` | `str` | required | | | `environment` | `str` | `"production"` | `"production"` (`login.salesforce.com`) or `"sandbox"` (`test.salesforce.com`). | | `login_url` | `str \| None` | `None` | My Domain host (e.g. `acme.my.salesforce.com`) — overrides `environment`. | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `openid email profile` (applied only when you pass no `scopes`). - Register `{base_url}{base_path}/callback/salesforce` as the callback URL on the connected app. - All endpoints live under `https://{host}/services/oauth2/` on the selected host. - Profile mapping: the user id comes from `user_id` (not `sub`); the avatar from `photos.picture` or `photos.thumbnail`. --- url: https://better-auth-py.oumarbarry.tech/providers/slack --- # Slack Sign in with Slack (`openid.connect` flavor). No PKCE, no id-token direct sign-in. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Slack auth = BetterAuth( secret=..., social_providers={ "slack": Slack(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "slack": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `openid profile email`. - Register `{base_url}{base_path}/callback/slack` as a redirect URL on the Slack app (Slack requires HTTPS redirect URLs). - Slack namespaces most userinfo claims under literal `https://slack.com/…` URIs: the user id is `https://slack.com/user_id`, and the avatar falls back to `https://slack.com/user_image_512` when `picture` is absent. --- url: https://better-auth-py.oumarbarry.tech/providers/spotify --- # Spotify Spotify OAuth2 with PKCE (S256). No id token. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Spotify auth = BetterAuth( secret=..., social_providers={ "spotify": Spotify(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "spotify": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `user-read-email`. - Register `{base_url}{base_path}/callback/spotify` as the redirect URI in the Spotify developer dashboard. - Avatar: Spotify's `images` is a size-ordered array — the first entry's `url` is used, `None` when empty. - `email_verified` is always `False` — Spotify's userinfo has no such claim. --- url: https://better-auth-py.oumarbarry.tech/providers/tiktok --- # TikTok TikTok Login Kit. OAuth2 with no PKCE and comma-joined scopes; TikTok uses `client_key` instead of `client_id` everywhere. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import TikTok auth = BetterAuth( secret=..., social_providers={ "tiktok": TikTok(client_key="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "tiktok": {"client_key": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_key` | `str` | required | Replaces `client_id` on the authorize URL, token exchange and refresh (TS types `clientId` as `never`). | | `client_secret` | `str` | required | | | `client_id` | `str \| list[str]` | `""` | Unused — TikTok never uses `client_id`; leave it empty. | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `user.info.profile`, joined with a **comma** in the authorize URL. - Register `{base_url}{base_path}/callback/tiktok` as the redirect URI in the TikTok developer portal. - The authorize URL is hand-built with TikTok's non-standard param ordering; token refresh sends `client_key` as an extra POST param. - User info requests the fields `open_id, avatar_large_url, display_name, username`; the profile is nested at `data.user`. TikTok returns no email — `email` falls back to `username` and `email_verified` is always `False`. --- url: https://better-auth-py.oumarbarry.tech/providers/twitch --- # Twitch Twitch OIDC-flavored OAuth2. No PKCE; user info comes from the decoded id token — no userinfo call. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Twitch auth = BetterAuth( secret=..., social_providers={ "twitch": Twitch(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "twitch": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | | `claims` | `list[str]` | `["email", "email_verified", "preferred_username", "picture"]` | Extra OIDC id-token claims requested via the `claims` authorize param — Twitch is the only provider that sends one. | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `user:read:email openid`. - Register `{base_url}{base_path}/callback/twitch` as an OAuth redirect URL in the Twitch developer console. - User info is the decoded (unverified, per TS `decodeJwt`) `id_token` — there is no network userinfo call, and no JWKS-backed direct id-token sign-in. - Display name comes from `preferred_username`. --- url: https://better-auth-py.oumarbarry.tech/providers/twitter --- # Twitter (X) X (Twitter) OAuth2 with PKCE (S256) and basic token-endpoint auth. Registry key stays `twitter`. No id token. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Twitter auth = BetterAuth( secret=..., social_providers={ "twitter": Twitter(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "twitter": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `users.read tweet.read offline.access users.email`. - Register `{base_url}{base_path}/callback/twitter` as the callback URI in the X developer portal. - Token-endpoint client auth is **basic** (standard RFC 7617 base64 — X rejects base64url). - The profile takes **two** calls to `/2/users/me`: one with `user.fields=profile_image_url`, one with `user.fields=confirmed_email` (X only returns email under that separate field query). A confirmed email sets `email_verified=True`; otherwise `email` falls back to the username, unverified. --- url: https://better-auth-py.oumarbarry.tech/providers/vercel --- # Vercel Sign in with Vercel. OAuth2 with **required** PKCE (S256); no default scopes. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Vercel auth = BetterAuth( secret=..., social_providers={ "vercel": Vercel(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "vercel": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: none — a `scope` param is only sent when you configure `scopes` explicitly. - Register `{base_url}{base_path}/callback/vercel` as the redirect URI on the Vercel integration. - PKCE is mandatory — building the authorize URL without a code verifier raises (matching TS, where every other PKCE provider just omits the challenge silently). - Display name prefers `name`, falling back to `preferred_username`. --- url: https://better-auth-py.oumarbarry.tech/providers/vk --- # VK VK ID OAuth2 with PKCE (S256). No id-token direct sign-in. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import VK auth = BetterAuth( secret=..., social_providers={ "vk": VK(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "vk": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | The VK ID app id. | | `client_secret` | `str` | required | | | `scheme` | `str \| None` | `None` | Parity field from TS's `VkOption` surface (UI hint); not read by the flow. | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `email phone`. - Register `{base_url}{base_path}/callback/vk` as the redirect URI in the VK ID app settings. - User info is a **POST** to `https://id.vk.com/oauth2/user_info` with a form body (`access_token` + `client_id`), not a bearer header; the profile is nested under `user`, and the name is `first_name last_name`. - No email on the account means the sign-in is rejected by the callback's email-required gate (TS returns `null`); `email_verified` is always `False`. --- url: https://better-auth-py.oumarbarry.tech/providers/wechat --- # WeChat WeChat QR-code login (`snsapi_login`). The most non-standard provider: `appid`/`secret` instead of `client_id`/`client_secret` on the wire, GET-based token exchange, comma-joined scopes, no PKCE. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import WeChat auth = BetterAuth( secret=..., social_providers={ "wechat": WeChat(client_id="wx-appid", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "wechat": {"client_id": "wx-appid", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | Your WeChat **AppID** — sent as `appid` on every request. | | `client_secret` | `str` | required | Your **AppSecret** — sent as `secret`. | | `lang` | `str` | `"cn"` | UI language of the QR login page (`"cn"` or `"en"`). | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: `snsapi_login`, joined with a **comma**. - Register `{base_url}{base_path}/callback/wechat` as the authorized callback domain/URL on the WeChat Open Platform app. - The authorize URL is hand-built on `https://open.weixin.qq.com/connect/qrconnect` and ends with the mandatory `#wechat_redirect` fragment. - Token exchange **and** refresh are `GET` requests with query-string params (`appid`, `secret`, …) against `api.weixin.qq.com/sns/oauth2/*` — not POST bodies. - The userinfo endpoint needs the `openid` returned alongside the access token; it is kept on the raw token response and read back at fetch time. The stable user id prefers `unionid` over `openid`. - WeChat never returns an email — a stable `{id}@wechat.invalid` placeholder is synthesized (always unverified) so the email-required callback doesn't reject the sign-in. --- url: https://better-auth-py.oumarbarry.tech/providers/zoom --- # Zoom Zoom OAuth2. PKCE is optional (on by default) — the only provider with a PKCE toggle. No id token, and no `scope` param ever. ## Configure ```python from better_auth import BetterAuth from better_auth.oauth.providers_ext import Zoom auth = BetterAuth( secret=..., social_providers={ "zoom": Zoom(client_id="…", client_secret="…"), }, ) ``` Or name-keyed (no import): ```python auth = BetterAuth( secret=..., social_providers={ "zoom": {"client_id": "…", "client_secret": "…"}, }, ) ``` ## Options | Field | Type | Default | Notes | | --- | --- | --- | --- | | `client_id` | `str \| list[str]` | required | | | `client_secret` | `str` | required | | | `use_pkce` | `bool` | `True` | TS's `pkce` option — `Zoom(..., use_pkce=False)` drops the challenge from the authorize URL. | All shared [`ProviderConfig` options](/providers/#per-provider-options) apply. ## Notes - Default scopes: none — the hand-built authorize URL never carries a `scope` param, even if you set `scopes` (matching TS, which ignores them for Zoom; scopes are configured on the Zoom app itself). - Register `{base_url}{base_path}/callback/zoom` as the redirect URL on the Zoom OAuth app. - The token exchange forwards the PKCE `code_verifier` unconditionally when present, even with `use_pkce=False` — only the authorize-URL side is gated (matching TS). - `email_verified` maps from Zoom's `verified` flag; the avatar from `pic_url`. --- url: https://better-auth-py.oumarbarry.tech/migrate/from-node --- # Migrating from Node The short version: point a Python service at the database your TypeScript Better Auth app already uses, and your users keep their passwords, their linked accounts, and their open sessions. Nobody is signed out. There is no export step, no dual-write window, and no password reset email to the whole user base. That works because the port treats the TypeScript repository as canonical for anything touching the wire or storage — same routes, same JSON, same columns, same crypto encodings. ## What is already compatible | | | | --- | --- | | **Tables** | `user`, `session`, `account`, `verification` — same names, same camelCase columns | | **Password hashes** | scrypt `N=16384, r=16, p=1, dkLen=64`, NFKC-normalized, hex `salt:key`. A hash written by the TypeScript library verifies in Python and the reverse | | **Session cookies** | `better-auth.session_token`, promoted to `__Secure-` over HTTPS; value is URI-encoded `token.sig`, signed HMAC-SHA256 | | **Routes and bodies** | `/sign-in/email`, `/get-session`, `/callback/{provider}` … same paths, same success and error JSON | | **Error codes** | Same strings and statuses — `INVALID_EMAIL_OR_PASSWORD` 401, `USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` 422 | | **Ids and tokens** | Same alphabets and lengths: 62-character ids, 64-character state and verification tokens | | **Encrypted values** | XChaCha20-Poly1305, cross-runtime compatible, for stored provider secrets | scrypt, XChaCha20-Poly1305, JWK and HOTP/TOTP are pinned in the test suite by vectors shared with the TypeScript implementation, so "compatible" is a test result rather than an intention. ## The migration **1. Use the same secret.** The cookie signature is HMAC-SHA256 with `BETTER_AUTH_SECRET`. A different secret invalidates every live session — which is exactly the thing you are trying to avoid. ```python auth = BetterAuth(secret=os.environ["BETTER_AUTH_SECRET"]) ``` **2. Use the same `base_url` and `base_path`.** The cookie name depends on the scheme (`__Secure-` over HTTPS) and `cookie_prefix`; the callback URL registered with each OAuth provider depends on both. ```python auth = BetterAuth( secret=..., base_url="https://example.com", # same origin as the Node app base_path="/api/auth", # the default, and Node's default ) ``` **3. Point at the same database. Do not run a migration.** ```python from sqlalchemy.ext.asyncio import create_async_engine from better_auth.adapters.sqlalchemy import SQLAlchemyAdapter adapter = SQLAlchemyAdapter(create_async_engine(os.environ["DATABASE_URL"])) auth = BetterAuth(secret=..., adapter=adapter) ``` The tables already exist with the right shape. `create_tables()` is a development convenience for a fresh database — skip it here. **4. Re-declare your options.** Configuration does not live in the database, so it has to be restated. TypeScript camelCase becomes Python snake_case, one to one: ```ts // auth.ts betterAuth({ emailAndPassword: { enabled: true, requireEmailVerification: true }, session: { expiresIn: 60 * 60 * 24 * 7, updateAge: 60 * 60 * 24 }, socialProviders: { github: { clientId: "…", clientSecret: "…" } }, plugins: [twoFactor({ issuer: "Example" }), organization()], }) ``` ```python # auth.py BetterAuth( secret=os.environ["BETTER_AUTH_SECRET"], base_url=os.environ["BETTER_AUTH_URL"], adapter=adapter, email_and_password=EmailAndPassword(enabled=True, require_email_verification=True), session=SessionOptions(expires_in=7 * 86400, update_age=86400), social_providers={"github": GitHub(client_id="…", client_secret="…")}, plugins=[TwoFactorPlugin(issuer="Example"), OrganizationPlugin()], ) ``` **5. Verify against the running database.** With both processes up, a session created by one must be readable by the other: ```bash # sign in against Node curl -s -c /tmp/jar -X POST https://node.example.com/api/auth/sign-in/email \ -H 'content-type: application/json' \ -d '{"email": "ada@example.com", "password": "…"}' # read it from Python — same cookie, same answer curl -s -b /tmp/jar https://python.example.com/api/auth/get-session ``` Because both runtimes are stateless over one database, you can cut over a percentage of traffic at the load balancer and roll back by moving it away. During the transition, Python services can also consume the still-running Node server over HTTP with [`better-auth-client`](https://pypi.org/project/better-auth-client/) — the wire is the same on both sides. ## Translating configuration | TypeScript | Python | | --- | --- | | `betterAuth({...})` | `BetterAuth(...)` | | `emailAndPassword` | `EmailAndPassword(...)` | | `emailVerification` | `EmailVerification(...)` | | `session` | `SessionOptions(...)` | | `session.cookieCache` | `CookieCache(...)` (from `better_auth.config`) | | `user` / `user.changeEmail` | `UserOptions(...)` / `ChangeEmailOptions(...)` (from `better_auth.config`) | | `account.accountLinking` | `AccountOptions(account_linking=AccountLinking(...))` | | `socialProviders` | `social_providers={...}` | | `rateLimit` | `RateLimit(...)` | | `trustedOrigins` | `trusted_origins=[...]` | | `secondaryStorage` | `secondary_storage=...` | | `advanced.ipAddress` | `ip_address=IPAddressOptions(...)` | | `advanced.database.generateId` | `AdvancedDatabase(generate_id=...)` on the adapter | | `baseURL.allowedHosts` | `DynamicBaseURL(allowed_hosts=[...])` | | `databaseHooks` | `database_hooks={...}` (same `model → op → phase` shape) | | `plugins: [twoFactor()]` | `plugins=[TwoFactorPlugin()]` | Plugin options follow the same rule: every constructor keyword is the TypeScript option in snake_case, with the same default. See the [plugin reference](/plugins/) and the [configuration page](/guide/configuration). ## Deliberate divergences None of these are visible on the wire, and all are documented where they live. - **Reset-password tokens are stored in the database.** Email-verification tokens stay stateless HS256 JWTs, as in TypeScript. - **Bearer reading is core, not a plugin.** `Authorization: Bearer ` works with no plugin installed; `BearerPlugin` here only adds the response-side `set-auth-token` header. - **`GET /get-session` and `POST /get-session` are both mounted**, matching the TypeScript router. ## Out of scope This is a **server-side** port. These are deliberately not implemented, per the project's parity decision log: - **SAML** (the SAML half of the `sso` plugin — OIDC federation is ported), **`scim`**, **`stripe`**. - **`open-api`** (developer tooling, no wire or storage contract) and the telemetry and logger option groups (logging stays on the standard library's `logging`). - The JavaScript **`client`**, **expo**, **electron** and **cli** packages. Your frontend does not need to change: the HTTP API is identical, so an existing `better-auth` JavaScript client keeps working unchanged against the Python server. For Python-side callers, the separate [`better-auth-client`](https://pypi.org/project/better-auth-client/) package covers the HTTP client role. Also still open, and not blockers for a migration: framework integrations beyond FastAPI (Litestar, Django, Flask) and CLI schema migrations. ## After the cutover Read [Production deploy](/deploy/production) for the parts that are infrastructure rather than parity — trusted proxy headers, rate-limit storage behind multiple workers, and secret rotation. --- url: https://better-auth-py.oumarbarry.tech/deploy/production --- # Production deploy Everything on this page is infrastructure rather than API surface: the five things that are fine on localhost and wrong in production. ## Secrets ```python auth = BetterAuth(secret=os.environ["BETTER_AUTH_SECRET"]) ``` ```bash openssl rand -base64 32 ``` At least 32 characters, or construction fails: ``` ValueError: secret must be at least 32 characters — generate one with `openssl rand -base64 32` ``` The secret signs session cookies, OAuth state and every derived key. Changing it signs everyone out, which is why rotation is versioned rather than a swap: ```python auth = BetterAuth( secret=os.environ["BETTER_AUTH_SECRET"], # the current one secrets=[ (2, os.environ["BETTER_AUTH_SECRET"]), (1, os.environ["BETTER_AUTH_SECRET_V1"]), # keep until old values expire ], ) ``` New values are written under the highest version; old ones keep verifying until you drop the pair. Retire a version once nothing signed with it can still be live — one `session.expires_in` window is the safe floor. ## A real adapter `MemoryAdapter` is the default so a quickstart runs with no setup. It is per-process and it forgets everything on restart. ```python from sqlalchemy.ext.asyncio import create_async_engine from better_auth.adapters.sqlalchemy import SQLAlchemyAdapter engine = create_async_engine(os.environ["DATABASE_URL"], pool_pre_ping=True) auth = BetterAuth(secret=..., adapter=SQLAlchemyAdapter(engine)) ``` Use real migrations. `await adapter.create_tables()` is a development convenience — in production, let Alembic own the schema so plugin tables and `additional_fields` are versioned with your code. ## `base_url` and HTTPS ```python auth = BetterAuth( secret=..., base_url="https://example.com", # not localhost, not http trusted_origins=["https://app.example.com"], # a separate frontend origin ) ``` An `https` `base_url` is what turns on `Secure` cookies and the `__Secure-` name prefix. It is also the origin that CSRF checks and every `callbackURL` and `redirectTo` are validated against — which is what makes open redirects impossible. A frontend on another origin must be listed in `trusted_origins` or its requests will be rejected. For one process behind several hostnames: ```python from better_auth import DynamicBaseURL auth = BetterAuth( secret=..., base_url=DynamicBaseURL( allowed_hosts=["example.com", "*.vercel.app"], protocol="https" ), ) ``` The base URL is derived per request from the `Host` header and restricted to `allowed_hosts`; each pattern also becomes a trusted origin. This is the option for preview deployments — for social sign-in from preview URLs specifically, see the [OAuth Proxy plugin](/plugins/oauth-proxy). ## Trust your proxy, not the client Behind a load balancer, the socket address is the proxy's. The client IP comes from a header, and a header can be forged — an attacker who controls `x-forwarded-for` defeats per-IP rate limiting entirely. ```python from better_auth import IPAddressOptions auth = BetterAuth( secret=..., ip_address=IPAddressOptions( ip_address_headers=["cf-connecting-ip", "x-forwarded-for"], trusted_proxies=["10.0.0.0/8"], # only these may set the header ), ) ``` List the CIDR ranges of your own proxies in `trusted_proxies`. The chain is walked from the right, and the first address outside the trusted set is the client. Set `disable_ip_tracking=True` if you would rather not store IPs at all. ## Rate limiting across workers ```python from better_auth import RateLimit auth = BetterAuth( secret=..., rate_limit=RateLimit( enabled=True, window=10, max=100, storage="database", # or "secondary-storage" custom_rules={"/sign-in/email": {"window": 10, "max": 3}}, ), ) ``` Better Auth's per-path rules ship built in. The trap is the default: `storage="memory"` counts per process, so four uvicorn workers means four times the limit. Use `"database"` for the shared adapter, or `"secondary-storage"` with a Redis-shaped store: ```python auth = BetterAuth( secret=..., secondary_storage=my_redis, rate_limit=RateLimit(enabled=True, storage="secondary-storage"), ) ``` Any object implementing the `SecondaryStorage` protocol works. `MemorySecondaryStorage` ships for tests. ## Running it ```bash uvicorn app:app --host 0.0.0.0 --port 8000 --workers 4 --proxy-headers ``` `--proxy-headers` is what makes uvicorn read `X-Forwarded-Proto`, so the application sees `https` and cookies come out `Secure`. Pair it with `--forwarded-allow-ips` set to your proxy's addresses. With more than one worker, in-process state is per worker: use a shared rate-limit store (above), and a shared `secondary_storage` if you use one at all. ### Vercel The Python function runtime serves an ASGI app directly, so `BetterAuthFastAPI` needs nothing special. Set `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL` and `DATABASE_URL` as environment variables, and use a connection pooler (Neon, Supabase pooler, PgBouncer) — serverless invocations open connections faster than a database wants. Preview deployments get a different hostname on every push, which breaks the one redirect URI registered with each OAuth provider. Two fixes: `DynamicBaseURL` with `allowed_hosts=["*.vercel.app"]`, or the [OAuth Proxy plugin](/plugins/oauth-proxy) to bounce callbacks through production. ## What is already hardened You do not have to configure these; they are the defaults. - **CSRF.** Non-GET requests are origin-checked against `base_url` and `trusted_origins`. - **Open redirects.** Every `callbackURL` and `redirectTo` is validated against trusted origins. - **User enumeration.** Sign-in runs a dummy scrypt when the user does not exist, so an unknown address and a wrong password take the same time and return the same 401. - **Password storage.** scrypt at `N=16384, r=16, p=1, dkLen=64`. - **Secrets at rest.** `AccountOptions(encrypt_oauth_tokens=True)` encrypts stored provider tokens with XChaCha20-Poly1305. - **Constant-time comparison** on the cookie-cache signature. Two options exist to turn parts of this off — `disable_csrf_check` and `disable_origin_check`. Both are for tests. If a production request is being rejected, the answer is an entry in `trusted_origins`. ## A production checklist - [ ] `secret` from the environment, ≥32 characters, never in the repository - [ ] A real adapter, with schema managed by migrations - [ ] `base_url` on `https`, real frontend origins in `trusted_origins` - [ ] `trusted_proxies` set if you are behind a load balancer - [ ] `rate_limit.storage` not `"memory"` when running more than one worker - [ ] `--proxy-headers` (and `--forwarded-allow-ips`) on uvicorn - [ ] Mailer callbacks wired: `send_reset_password`, `send_verification_email` - [ ] `encrypt_oauth_tokens=True` if you store provider tokens - [ ] `CookieCache` `max_age` small enough that revocation is timely