Authentication Overview
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.
What's Included
| 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.
Plugins
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.
Server Configuration
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" },ID Generation
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.
Client Configuration
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.
Auth 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 membershipSee the Better Auth API reference for the full endpoint list.
Database Tables
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 |
Auth Hint Cookie
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.
Environment Variables
| 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) |