- 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.
461 lines
21 KiB
Markdown
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.
|