# Better Auth Plugins on Cloudflare ## Research Question I am researching Better Auth plugins for username, phone-number OTP, organization, API key, and Expo. Because `cfw-auth` runs as a Cloudflare Worker on D1 and needs these capabilities without leaking platform-specific complexity into the auth model. So this project can keep one small auth core, add plugins deliberately, and know how to operate them in Cloudflare. Question type: method and decision. Evidence threshold: official Better Auth docs/package metadata, installed 1.6.x type definitions, Cloudflare Worker/D1 configuration, and local migration output. ## Source Evaluation | Source | Role | Use level | Key claim | Limits | | --- | --- | --- | --- | --- | | Better Auth plugin docs, `https://better-auth.com/docs/concepts/plugins` | Official docs | Core evidence | Plugins can add schema, endpoints, hooks, middleware, rate limits, and client behavior. | The site is a Next app, so command-line extraction is noisy. | | Better Auth plugin overview, `https://better-auth.com/docs/plugins` | Official docs | Core evidence | Better Auth groups 50+ plugins into authentication, authorization, API/token, provider, billing, security, utility, and analytics families. | Plugin availability can change between Better Auth releases. | | Better Auth username docs, `https://better-auth.com/docs/plugins/username` | Official docs | Core evidence | Username adds username fields and sign-in/availability endpoints. | Exact runtime behavior should be checked against installed package types. | | Better Auth phone-number docs, `https://better-auth.com/docs/plugins/phone-number` | Official docs | Core evidence | Phone number supports OTP sending, verification, sign-in, and password reset by phone. | SMS provider is intentionally outside Better Auth. | | `better-auth@1.6.x` installed type definitions | Local package evidence | Core evidence | Confirms endpoint paths, schema additions, option names, and plugin ids used by this repo. | Tied to the installed version; re-check after dependency upgrades. | | `@better-auth/api-key@1.6.x` installed type definitions | Local package evidence | Core evidence | Confirms API key table, key hashing, rate limit fields, user/org references, and endpoints. | Does not replace production threat modeling for API key usage. | | `@better-auth/expo@1.6.x` package README | Maintained package docs | Core evidence | Expo server plugin plus Expo client plugin, trusted custom scheme, and SecureStore-backed storage. | Client-side Expo app is outside this Worker repo. | | `@better-auth/i18n@1.6.x` installed type definitions | Local package evidence | Core evidence | Confirms the i18n plugin is a separate package and translates Better Auth error messages through after hooks. | Translation coverage is project-owned. | | Cloudflare Wrangler config docs, `https://developers.cloudflare.com/workers/wrangler/configuration/` | Platform docs | Core evidence | Worker bindings, vars, compatibility dates, and flags are configured through Wrangler config. | Does not document Better Auth behavior. | | Local target schema `docs/schema/better-auth-target.sql` | Project evidence | Core evidence | Current plugin set creates username, phone, organization/team/member/invitation, apikey, and jwks persistence. | Generated target SQL must stay in sync with Better Auth config; D1 migrations remain incremental. | ## Plugin Ecosystem Map Better Auth's plugin ecosystem has four useful categories: | Category | Examples in this repo | When to use | Cloudflare note | | --- | --- | --- | --- | | Identity and sign-in methods | `username`, `phoneNumber`, `emailOTP`, `passkey`, social providers | Add new ways to prove or discover a user identity. | Provider callbacks must use `fetch`, Worker-compatible crypto, and correct `trustedOrigins`. | | Account and session controls | `twoFactor`, `multiSession`, `lastLoginMethod`, `admin`, `bearer`, `jwt`, `i18n` | Add account security, admin operations, alternate token formats, localized errors, or session policy. | Avoid enabling token formats by default; each one expands the security surface. | | B2B and authorization | `organization`, teams, members, invitations, roles | Model tenants, memberships, invitations, and per-organization access. | Keep tenant authorization in the auth boundary; expose projections to apps. | | API and machine access | `apiKey` | Let users or organizations create revocable machine credentials. | Hash keys, prefer explicit prefixes, and be careful with database-backed rate counters on D1. | | Client/runtime integration | `expo`, OpenAPI | Adapt auth behavior to client platforms or operational tooling. | Expo needs a trusted custom scheme; OpenAPI helps smoke-test the Worker. | | Abuse prevention | `captcha`, `haveIBeenPwned` | Reduce automated abuse or weak passwords. | Turnstile is a natural Cloudflare fit; configure secrets as Worker secrets. | ## Current `cfw-auth` Fit The current code already follows the right high-level Cloudflare shape: - `src/auth.ts` passes the D1 binding as `database: env.DB`. - `src/index.ts` mounts Better Auth under `/api/auth/*` through Hono and forwards Worker background work through `ctx.executionCtx.waitUntil`. - `wrangler.jsonc` declares the D1 binding, compatibility date, `nodejs_compat`, and non-secret vars. - `src/auth.migration.ts` uses an in-memory SQLite database for Better Auth CLI schema generation, then Wrangler applies generated SQL to D1. - `docs/auth-migrations.md` correctly says not to use `auth migrate` for D1; Wrangler should own D1 migration history. The main operational rule is: plugin configuration that affects persistence must be present in both runtime auth config and migration auth config. ## Plugin Notes ### i18n Use for translating Better Auth error messages based on request locale. Installed package evidence confirms: - Package: `@better-auth/i18n` - Plugin id: `i18n` - Client package: `@better-auth/i18n/client` - Detection strategies: `header`, `cookie`, `session`, and `callback` - It translates server error messages through an after hook and does not add persistence schema by itself. `cfw-auth` choice: ```ts i18n({ translations: { en: {}, zh: { USER_NOT_FOUND: "用户不存在。", INVALID_EMAIL_OR_PASSWORD: "邮箱或密码无效。", }, }, defaultLocale: "en", detection: ["header", "cookie"], localeCookie: "cfw_auth_locale", }) ``` Best practices: - Keep English as the fallback locale until all user-facing flows have a complete translated copy path. - Prefer `Accept-Language` plus an explicit locale cookie. Avoid guessing from IP. - Translate stable error codes, not arbitrary log messages. - Do not leak account-existence details through different translations. ### Username Use for human-friendly account identifiers and sign-in by username/password. Installed package evidence confirms: - Plugin id: `username` - User fields: `username`, `displayUsername` - Endpoints: `POST /sign-in/username`, `POST /is-username-available` - `username` is unique and returned in user data. `cfw-auth` choice: ```ts username({ minUsernameLength: 3, maxUsernameLength: 32, validationOrder: { username: "post-normalization", }, }) ``` Best practices: - Normalize before uniqueness matters. The default normalization is lowercase. - Keep username separate from display name. Usernames are identifiers; display names are presentation. - Do not let username replace email verification when email is still part of account recovery. ### Phone Number and SMS OTP Use for phone verification, phone sign-in, phone-based account creation, and phone password reset. Installed package evidence confirms: - Plugin id: `phone-number` - User fields: `phoneNumber`, `phoneNumberVerified` - Endpoints: `POST /phone-number/send-otp`, `POST /phone-number/verify`, `POST /sign-in/phone-number`, `POST /phone-number/request-password-reset`, `POST /phone-number/reset-password` - `phoneNumber` is unique. `cfw-auth` choice: ```ts phoneNumber({ otpLength: 6, expiresIn: 300, allowedAttempts: 3, requireVerification: true, phoneNumberValidator: (value) => /^\+[1-9]\d{7,14}$/.test(value), signUpOnVerification: { getTempEmail: (value) => `phone-${value.replace(/\D/g, "")}@phone.cfw-auth.local`, getTempName: (value) => value, }, sendOTP: async ({ phoneNumber, code }) => sendSms(...), }) ``` Best practices: - Store and accept E.164-style numbers only. The current validator enforces a leading `+` and a sane digit range. - Treat SMS as possession proof, not high-assurance identity proof. - Keep SMS provider code in an adapter (`src/sms.ts`), because Twilio, webhook relays, and future Cloudflare-native services should not change the auth core. - Decide whether phone-created users may later add a real email, because Better Auth still has a required email shape in the user model. ### Organization Use for B2B tenancy: organizations, members, invitations, teams, and roles. Installed package evidence confirms: - Plugin id: `organization` - Main concepts: organization, member, invitation, team, teamMember - Endpoints cover create/update/delete organization, set active organization/team, invitations, members, team management, and permission checks. - `sendInvitationEmail` must construct the invitation URL; Better Auth gives the invitation id and data. `cfw-auth` choice: ```ts organization({ teams: { enabled: true, defaultTeam: { enabled: true }, }, requireEmailVerificationOnInvitation: true, cancelPendingInvitationsOnReInvite: true, sendInvitationEmail: async (data) => { const url = `${appOrigin}/accept-invitation?id=${encodeURIComponent(data.id)}`; await sendEmail(...); }, }) ``` Best practices: - Treat organization as a tenant boundary, not only a UI grouping. - Keep membership and invitation authorization inside Better Auth unless a real domain rule requires a separate app policy. - Avoid dynamic roles until fixed roles are insufficient. Dynamic access control is available but adds operational complexity. - Build app-specific projections from organization/member state instead of letting app tables become the source of auth truth. ### API Key Use for machine-to-machine access owned by a user or organization. Installed package evidence confirms: - Package: `@better-auth/api-key` - Plugin id: `api-key` - Table: `apikey` - Endpoints include `POST /api-key/create`, `POST /api-key/verify`, `GET /api-key/get`, `POST /api-key/update`, `POST /api-key/delete`, `GET /api-key/list`, and `POST /api-key/delete-all-expired-api-keys`. - Key hashing is enabled by default; disabling it is explicitly warned against in package types. - `references` can be `user` or `organization`. `cfw-auth` choice: ```ts apiKey([ { configId: "default", defaultPrefix: "cfw_", requireName: true, enableMetadata: true, rateLimit: { enabled: true, timeWindow: 86_400_000, maxRequests: 1_000 }, }, { configId: "organization", references: "organization", defaultPrefix: "cfw_org_", requireName: true, enableMetadata: true, rateLimit: { enabled: true, timeWindow: 86_400_000, maxRequests: 10_000 }, }, ]) ``` Best practices: - Keep hashing enabled. - Use distinct prefixes for human debugging and incident response. - Prefer organization-referenced keys for shared service integrations. - Do not enable `enableSessionForAPIKeys` unless the app explicitly accepts API keys acting like user sessions. - On D1, database-backed rate limiting is acceptable for moderate control, but not a substitute for edge/WAF-level abuse controls. - If latency becomes a problem, `deferUpdates` exists but requires `advanced.backgroundTasks.handler` and accepts eventual consistency. ### Expo Use when an Expo or React Native client needs native session/cookie handling and deep-link callbacks. Package evidence confirms: - Server import: `import { expo } from "@better-auth/expo"` - Client import: `import { expoClient } from "@better-auth/expo/client"` - Expo client should use `expo-secure-store` for secure session/cookie storage. - `trustedOrigins` must include the app scheme, for example `cfwauth://`. `cfw-auth` choice: ```ts expo() ``` and: ```ts export function trustedOrigins(env: Env): string[] { return [...csvEnv(env.TRUSTED_ORIGINS), ...expoOrigins(env)]; } export function expoOrigins(env: Env): string[] { return csvEnv(env.EXPO_SCHEME).map((scheme) => `${scheme}://`); } ``` Best practices: - Keep Expo scheme configuration explicit through `EXPO_SCHEME`. - The Worker does not need Expo client packages; the mobile app does. - Configure `baseURL` to the Worker auth URL and ensure CORS accepts the web origins that need cookies. ### Captcha Use for high-abuse public endpoints such as email sign-up, email sign-in, and password reset. Installed package evidence confirms: - Plugin id: `captcha` - It runs as `onRequest` protection and does not add schema. - Current repo uses Cloudflare Turnstile when `CAPTCHA_PROVIDER=cloudflare-turnstile` and `CAPTCHA_SECRET_KEY` are set. `cfw-auth` choice: ```ts captcha({ provider: "cloudflare-turnstile", secretKey: env.CAPTCHA_SECRET_KEY, endpoints: ["/sign-up/email", "/sign-in/email", "/forget-password"], }) ``` Best practices: - Keep the Turnstile secret in Wrangler secrets. - Protect only high-risk public endpoints first; do not force CAPTCHA into every authenticated request. - Treat CAPTCHA as abuse reduction, not account security. ### Generic OAuth Use when a provider is not one of Better Auth's built-in social providers or when an enterprise IdP is configured from discovery metadata. Installed package evidence confirms: - Plugin id: `generic-oauth` - It adds OAuth2 sign-in, callback, and account-linking endpoints. - It contributes social providers through plugin initialization and reuses the normal `account` table. `cfw-auth` choice: ```ts genericOAuth({ config: [ { providerId: env.GENERIC_OAUTH_PROVIDER_ID, discoveryUrl: env.GENERIC_OAUTH_DISCOVERY_URL, clientId: env.GENERIC_OAUTH_CLIENT_ID, clientSecret: env.GENERIC_OAUTH_CLIENT_SECRET, scopes: ["openid", "email", "profile"], }, ], }) ``` Best practices: - Keep provider config in env so one Worker build can serve different environments. - Prefer OIDC discovery URLs over hand-maintained endpoint URLs when the provider supports discovery. - Keep provider IDs stable; changing them can split account linkage. ### JWT and Bearer Use JWT when another service needs a signed token and JWKS endpoint. Use Bearer when API callers need to present bearer tokens to Better Auth endpoints. Installed package evidence confirms: - `jwt` plugin id: `jwt` - `jwt` adds token/JWKS/sign/verify endpoints. - `jwt` adds a `jwks` schema with public/private key material and key expiry. - `bearer` plugin id: `bearer` - `bearer` is hook-only and does not add schema. `cfw-auth` choice: ```ts if (booleanEnv(env.ENABLE_JWT)) { plugins.push(jwt()); } if (booleanEnv(env.ENABLE_BEARER)) { plugins.push(bearer()); } ``` Best practices: - Keep JWT disabled until a concrete downstream service needs it. - Do not replace browser cookie sessions with JWT. - Because `jwt` creates `jwks`, keep it in `src/auth.migration.ts` even if runtime activation is env-gated. - Keep Bearer disabled until a client actually needs token-to-session conversion. ## Cloudflare Runtime Checklist - Use D1 binding directly as the Better Auth database for current Better Auth versions. - Keep `BETTER_AUTH_SECRET`, provider API keys, CAPTCHA secret, and SMS provider secrets in Wrangler secrets. - Keep non-secret URL/origin/provider toggles in `wrangler.jsonc`. - Use `ctx.executionCtx.waitUntil` through Better Auth `advanced.backgroundTasks.handler`. - Use `fetch`, Web Crypto-compatible code, and Worker-safe APIs in adapters. - Generate SQL with Better Auth CLI, commit it, and apply with `wrangler d1 migrations apply`. - Re-run `pnpm db:check`, `pnpm typecheck`, and `pnpm test` after plugin changes. ## Minimal Domain Model ### Business Slice Workflow: a person or integration authenticates to a Cloudflare-hosted auth service, joins or creates an organization, and may issue an API key for user or organization access. Actors: user, organization owner/admin/member, invited user, API client, Expo mobile client. Commands: - Sign up or sign in by email/password, username/password, phone OTP, OAuth, passkey, or Expo-backed client flow. - Verify email or phone. - Create organization, invite member, accept invitation, set active organization/team. - Create, verify, update, revoke, or list API keys. Events: - UserRegistered - IdentifierVerified - OrganizationCreated - MemberInvited - InvitationAccepted - ApiKeyCreated - ApiKeyVerified - ApiKeyRevoked Policies: - Email verification gates invitation acceptance. - Phone sign-up requires OTP verification. - API keys are named, hashed, rate-limited, and scoped to user or organization reference. - Expo schemes must be trusted origins. ### Minimal Core Model | Concept | Type | Meaning | Key rules | | --- | --- | --- | --- | | User | Entity | Human account in Better Auth. | Owns identifiers and sessions. | | Identifier | Value object | Email, username, phone, passkey, OAuth account. | Verification rules differ by identifier type. | | Organization | Entity | Tenant boundary. | Has unique slug and membership. | | Membership | Entity | User's role in an organization or team. | Permission checks should flow through membership. | | Invitation | Entity | Pending request to join an organization. | Email proof and expiry matter. | | API Key | Entity | Machine credential scoped to a reference. | Key value is secret and hashed; lifecycle is revocable. | | Client Runtime | Boundary | Web, Expo native, API client. | Affects transport and storage, not core identity semantics. | ### Invariants - A username, email, and phone number must not identify two users. - A phone login/registration must not complete without valid OTP verification when `requireVerification` is enabled. - An API key's raw value is only shown at creation; persisted value must be hashed. - Organization membership is the authority for organization permissions. - D1 migration SQL must match the runtime plugin set. ### Aggregates / Consistency Boundaries | Aggregate | Root | Invariants protected | Outside coordination | | --- | --- | --- | --- | | Account | User | Unique identifiers, verification status, password/session policy. | Email/SMS/OAuth providers. | | Organization | Organization | Slug, teams, memberships, invitations. | Email invitation delivery, app-specific billing/entitlements. | | API Key | API key | Hashing, ownership reference, revocation, rate counters. | Edge/WAF abuse controls, downstream API authorization. | ### Bounded Contexts | Context | Owns | Language boundary | Integrates with | Translation needed | | --- | --- | --- | --- | --- | | Auth | users, sessions, identifiers, verification | Identity proof and session lifecycle | Email, SMS, OAuth, passkey | Provider payloads to Better Auth user/account model | | Tenant | organizations, members, teams, invitations | B2B access and membership | Auth users, app domains | Organization/member projection to app authorization | | Credential | API keys | Machine access and key lifecycle | Tenant, downstream APIs | API key verification result to service principal | | Client Integration | Expo/web/API clients | Transport, redirect, secure storage | Auth endpoints | Scheme/origin/client storage to Better Auth client plugins | ### Edge Complexity - SMS providers stay behind `src/sms.ts`. - Email providers stay behind `src/email.ts`. - OAuth providers stay behind `src/oauth.ts`. - Expo scheme handling stays in `trustedOrigins`. - D1 migration generation stays in `src/auth.migration.ts`. ### Change Tests | Change | Expected impact | Boundary result | | --- | --- | --- | | Add a new SMS vendor | Edit `src/sms.ts` and env docs only. | Good adapter boundary. | | Add paid organization limits | Add organization policy/hook or billing integration, not new auth identity model. | Tenant boundary absorbs it. | | Add mobile app scheme | Update `EXPO_SCHEME` and trusted origin tests. | Client boundary absorbs it. | | Add service account keys | Extend API key metadata/permissions or add a new config id. | Credential boundary absorbs it. | | Switch D1 migration strategy | Update migration docs/scripts, not runtime plugin semantics. | Persistence boundary absorbs it. | ## Research Limits and Next Questions - The official docs site is dynamically rendered; package type definitions are the most precise local evidence for the installed version. - This note does not decide UI workflows for accepting invitations, phone-only users adding email, or API key permission statements. - Before production, add threat modeling for phone number enumeration, OTP abuse, API key leakage, and organization invitation takeover.