--- url: https://better-auth-py.oumarbarry.tech/ ---
:"` 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