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

461 lines
21 KiB
Markdown

# 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.