GenerateSaaS

Authentication

Authenticate users with the @repo/auth Better Auth instance shared across the backend.

@repo/auth wraps Better Auth into one server instance (export const auth in packages/auth/src/config.ts), served through the shared @repo/api backend. Auth is always on; individual capabilities gate on @repo/config keys.

The browser client lives at apps/web-next/lib/auth-client.ts - createAuthClient from better-auth/react, wiring the matching client plugins (org, 2FA, magic-link, last-login-method, admin, passkey, API-key, Stripe, Polar) and inferring user/org additional fields from the server auth type.

What you get

CapabilityDetail
Email / passwordEnabled unconditionally; 12-128 char passwords, requireEmailVerification: true blocks sign-in until confirmed
Magic linkPasswordless link via signIn.magicLink({ email }), expires in 15 minutes
SessionsStored in Redis via secondaryStorage, 5-minute cookie cache; lastActiveAt is stamped on session create and update
Account deletiondeleteUser.enabled: true - removes the avatar via @repo/storage and emits a user/deleted Inngest event
Social OAuthPer-provider buttons gated by config.auth.socialProviders
2FA + passkeysAuthenticator TOTP, backup codes, WebAuthn passkeys
RolesUserRole (platform) and OrgRole (per-organization)
API keysRegistered only when config.apiKeys.enabled; keys use the config.apiKeys.prefix (default key_) and grant a session
CAPTCHAOff by default in generated projects ({ enabled: false }); Cloudflare Turnstile when config.captcha.enabled and TURNSTILE_SECRET_KEY is set
Rate limitingOn by default, backed by Redis (secondary-storage); 300 req/min global plus tighter per-route caps (see below)
Audit + notificationsEach sign-in writes a LOGIN_SUCCESS audit entry; revoking a device fires notifySessionRevoked

packages/database/src/db/auth.ts is generated by Better Auth - run pnpm auth:generate, never edit it by hand. Add a user column under user.additionalFields in packages/auth/src/config.ts (as with country, phone, onboardingCompleted), then regenerate.

Rate limits

Better Auth rate-limiting is on (rateLimit.enabled: true), stored in Redis. The global window is 300 req / 60 s; sensitive routes tighten it. Hitting a limit returns 429.

RouteLimit (per 60 s)
/sign-in/email10
/sign-up/email5
/sign-in/magic-link, /request-password-reset, /send-verification-email, /change-email, /organization/invite-member3
/two-factor/verify-otp, /two-factor/verify-totp, /two-factor/verify-backup-code5
/get-session, /organization/list, /organization/get-full-organization1000

Waitlist mode

When config.waitlist is true, sign-up swaps verification and magic-link emails for the waitlist-confirmation template and disables autoSignInAfterVerification - users confirm but are not signed in. Default is false.

Cross-subdomain cookies

Set the AUTH_COOKIE_DOMAIN env var (e.g. .example.com) to share the session cookie across subdomains. Better Auth then enables crossSubDomainCookies and switches cookies to SameSite=None; Secure; Partitioned. Unset, cookies are SameSite=Lax (and Secure only in production).

Explore

Frequently asked questions

Can I disable email/password and use only magic links? Not via a flag - emailAndPassword.enabled is hardcoded true in packages/auth/src/config.ts. Edit the config directly if you want a passwordless-only flow.

What does account deletion clean up? Foreign keys cascade the user's accounts, passkeys, members, invitations, twoFactors, apikeys, and subscriptions. Sessions live in Redis (not Postgres), so they are evicted there rather than cascaded.

Where do I read the current user? Read session.user from a server component, or use useSession() from the client in apps/web-next/lib/auth-client.ts. See Data fetching.

How do I add a custom field to the user? Add it under user.additionalFields in packages/auth/src/config.ts, then run pnpm auth:generate to regenerate the schema. The client infers it automatically via inferAdditionalFields.

On this page