Authentication is handled by Better Auth – a TypeScript-native auth framework that runs entirely in the API worker. The project ships with multiple sign-in methods, organization-based multi-tenancy, and Stripe billing integration out of the box.
| Method | Description |
|---|---|
| Email & OTP | Passwordless 6-digit code via email |
| Email & Password | Traditional email/password with reset |
| Google OAuth | Social login with redirect flow |
| Passkeys | WebAuthn biometric / security key |
| Anonymous | Guest sessions that can be upgraded later |
All methods produce the same session format. Users can link multiple methods to one account.
Better Auth’s functionality is extended through plugins. The server and client must enable matching plugins:
| Plugin | Server | Client | Purpose |
|---|---|---|---|
emailOTP |
emailOTP() |
emailOTPClient() |
Passwordless OTP sign-in |
organization |
organization() |
organizationClient() |
Multi-tenant orgs and roles |
passkey |
passkey() |
passkeyClient() |
WebAuthn authentication |
anonymous |
anonymous() |
anonymousClient() |
Guest sessions |
stripe |
stripe() |
stripeClient() |
Subscription billing |
The Stripe plugin is conditionally loaded – it only activates when STRIPE_SECRET_KEY and related env vars are set. Without them, the app works normally but billing endpoints return 404.
The auth instance is created per-request in apps/api/lib/auth.ts:
// apps/api/lib/auth.ts
export function createAuth(db: DB, env: AuthEnv) {
return betterAuth({
baseURL: `${env.APP_ORIGIN}/api/auth`,
trustedOrigins: [env.APP_ORIGIN],
secret: env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, { provider: "pg", schema: { ... } }),
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url }) => {
await sendPasswordReset(env, { user, url });
},
},
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
await sendVerificationEmail(env, { user, url });
},
},
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
},
plugins: [
anonymous(),
organization({
allowUserToCreateOrganization: true,
organizationLimit: 5,
creatorRole: "owner",
}),
passkey({ rpID, rpName: env.APP_NAME, origin: env.APP_ORIGIN }),
emailOTP({ otpLength: 6, expiresIn: 300, allowedAttempts: 3 }),
...stripePlugin(db, env),
],
});
}
The account model is renamed to identity to better describe its purpose (OAuth provider credentials):
account: { modelName: "identity" },
All auth tables use prefixed CUID2 IDs generated at the application level:
advanced: {
database: {
generateId: ({ model }) => generateAuthId(model),
},
},
This produces IDs like usr_cm..., ses_cm..., org_cm... – making it easy to identify what kind of record an ID refers to.
The auth client lives in apps/app/lib/auth.ts:
// apps/app/lib/auth.ts
import { createAuthClient } from "better-auth/react";
export const auth = createAuthClient({
baseURL: baseURL + "/api/auth",
plugins: [
anonymousClient(),
emailOTPClient(),
organizationClient(),
passkeyClient(),
stripeClient({ subscription: true }),
],
});
::: warning
Do not use auth.useSession() directly. Session state is managed exclusively through TanStack Query – see Sessions & Protected Routes.
:::
Better Auth exposes HTTP endpoints at /api/auth/*. These are mounted in the Hono app alongside tRPC:
/api/auth/sign-in/* Sign-in endpoints (email, social, passkey)
/api/auth/sign-up/* Sign-up endpoints
/api/auth/sign-out Session termination
/api/auth/get-session Current session data
/api/auth/callback/* OAuth callbacks
/api/auth/email-otp/* OTP send and verify
/api/auth/passkey/* WebAuthn registration and authentication
/api/auth/organization/* Organization CRUD and membership
See the Better Auth API reference for the full endpoint list.
Authentication uses 9 database tables defined in db/schema/:
| Table | File | Description |
|---|---|---|
user |
user.ts |
User accounts with profile info |
session |
user.ts |
Active sessions with activeOrganizationId |
identity |
user.ts |
OAuth provider credentials (Better Auth’s account model) |
verification |
user.ts |
Email verification and OTP tokens |
organization |
organization.ts |
Tenant organizations |
member |
organization.ts |
Organization memberships with roles |
invitation |
invitation.ts |
Pending org invitations |
passkey |
passkey.ts |
WebAuthn credential store |
subscription |
subscription.ts |
Stripe subscription state |
The API worker sets a lightweight cookie (__Host-auth in HTTPS, auth in HTTP dev) on sign-in and clears it on sign-out. The web edge worker reads this cookie to route / – authenticated users get the app, anonymous users get the marketing page. This cookie is a routing hint only, not a security boundary. See ADR-001 for the full rationale.
| Variable | Required | Description |
|---|---|---|
BETTER_AUTH_SECRET |
Yes | Secret for signing sessions and tokens |
GOOGLE_CLIENT_ID |
Yes | Google OAuth client ID |
GOOGLE_CLIENT_SECRET |
Yes | Google OAuth client secret |
RESEND_API_KEY |
Yes | API key for sending OTP emails |
RESEND_EMAIL_FROM |
Yes | Sender address for auth emails |
APP_NAME |
Yes | Display name (used in emails and passkey prompts) |
APP_ORIGIN |
Yes | Full origin URL (e.g., https://example.com) |