Files
cfw-auth/docs/better-auth-cloudflare-plugin-research.md
imeepos e3057928a2 feat: add better authentication features and organization management
- Introduced new database migration for enhanced user and organization management.
- Updated package dependencies to include new Better Auth modules for API keys, Expo, and i18n.
- Implemented SMS functionality for phone verification and password resets.
- Enhanced authentication plugins with username and phone number support.
- Added performance configuration options for session cookie caching and API key updates.
- Updated email templates to include organization invitation messages.
- Improved testing coverage for new features and configurations.
2026-06-10 19:39:15 -07:00

21 KiB

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:

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:

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:

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:

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:

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:

expo()

and:

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:

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:

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:

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.