Skip to content

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

MethodDescription
Email & OTPPasswordless 6-digit code via email
Email & PasswordTraditional email/password with reset
Google OAuthSocial login with redirect flow
PasskeysWebAuthn biometric / security key
AnonymousGuest 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:

PluginServerClientPurpose
emailOTPemailOTP()emailOTPClient()Passwordless OTP sign-in
organizationorganization()organizationClient()Multi-tenant orgs and roles
passkeypasskey()passkeyClient()WebAuthn authentication
anonymousanonymous()anonymousClient()Guest sessions
stripestripe()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:

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):

ts
account: { modelName: "identity" },

ID Generation

All auth tables use prefixed CUID2 IDs generated at the application level:

ts
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:

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 membership

See the Better Auth API reference for the full endpoint list.

Database Tables

Authentication uses 9 database tables defined in db/schema/:

TableFileDescription
useruser.tsUser accounts with profile info
sessionuser.tsActive sessions with activeOrganizationId
identityuser.tsOAuth provider credentials (Better Auth's account model)
verificationuser.tsEmail verification and OTP tokens
organizationorganization.tsTenant organizations
memberorganization.tsOrganization memberships with roles
invitationinvitation.tsPending org invitations
passkeypasskey.tsWebAuthn credential store
subscriptionsubscription.tsStripe 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.

Environment Variables

VariableRequiredDescription
BETTER_AUTH_SECRETYesSecret for signing sessions and tokens
GOOGLE_CLIENT_IDYesGoogle OAuth client ID
GOOGLE_CLIENT_SECRETYesGoogle OAuth client secret
RESEND_API_KEYYesAPI key for sending OTP emails
RESEND_EMAIL_FROMYesSender address for auth emails
APP_NAMEYesDisplay name (used in emails and passkey prompts)
APP_ORIGINYesFull origin URL (e.g., https://example.com)