- 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.
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.tspasses the D1 binding asdatabase: env.DB.src/index.tsmounts Better Auth under/api/auth/*through Hono and forwards Worker background work throughctx.executionCtx.waitUntil.wrangler.jsoncdeclares the D1 binding, compatibility date,nodejs_compat, and non-secret vars.src/auth.migration.tsuses an in-memory SQLite database for Better Auth CLI schema generation, then Wrangler applies generated SQL to D1.docs/auth-migrations.mdcorrectly says not to useauth migratefor 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, andcallback - 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-Languageplus 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 usernameis 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 phoneNumberis 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.
sendInvitationEmailmust 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, andPOST /api-key/delete-all-expired-api-keys. - Key hashing is enabled by default; disabling it is explicitly warned against in package types.
referencescan beuserororganization.
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
enableSessionForAPIKeysunless 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,
deferUpdatesexists but requiresadvanced.backgroundTasks.handlerand 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-storefor secure session/cookie storage. trustedOriginsmust include the app scheme, for examplecfwauth://.
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
baseURLto 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
onRequestprotection and does not add schema. - Current repo uses Cloudflare Turnstile when
CAPTCHA_PROVIDER=cloudflare-turnstileandCAPTCHA_SECRET_KEYare 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
accounttable.
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:
jwtplugin id:jwtjwtadds token/JWKS/sign/verify endpoints.jwtadds ajwksschema with public/private key material and key expiry.bearerplugin id:bearerbeareris 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
jwtcreatesjwks, keep it insrc/auth.migration.tseven 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.waitUntilthrough Better Authadvanced.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, andpnpm testafter 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
requireVerificationis 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.