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
| Capability | Detail |
|---|---|
| Email / password | Enabled unconditionally; 12-128 char passwords, requireEmailVerification: true blocks sign-in until confirmed |
| Magic link | Passwordless link via signIn.magicLink({ email }), expires in 15 minutes |
| Sessions | Stored in Redis via secondaryStorage, 5-minute cookie cache; lastActiveAt is stamped on session create and update |
| Account deletion | deleteUser.enabled: true - removes the avatar via @repo/storage and emits a user/deleted Inngest event |
| Social OAuth | Per-provider buttons gated by config.auth.socialProviders |
| 2FA + passkeys | Authenticator TOTP, backup codes, WebAuthn passkeys |
| Roles | UserRole (platform) and OrgRole (per-organization) |
| API keys | Registered only when config.apiKeys.enabled; keys use the config.apiKeys.prefix (default key_) and grant a session |
| CAPTCHA | Off by default in generated projects ({ enabled: false }); Cloudflare Turnstile when config.captcha.enabled and TURNSTILE_SECRET_KEY is set |
| Rate limiting | On by default, backed by Redis (secondary-storage); 300 req/min global plus tighter per-route caps (see below) |
| Audit + notifications | Each 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.
| Route | Limit (per 60 s) |
|---|---|
/sign-in/email | 10 |
/sign-up/email | 5 |
/sign-in/magic-link, /request-password-reset, /send-verification-email, /change-email, /organization/invite-member | 3 |
/two-factor/verify-otp, /two-factor/verify-totp, /two-factor/verify-backup-code | 5 |
/get-session, /organization/list, /organization/get-full-organization | 1000 |
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
Social OAuth
Wire Google, GitHub, Facebook, Discord, and X via config.auth.socialProviders.
Two-factor & passkeys
Authenticator TOTP, backup codes, and WebAuthn passkeys.
Authorization
UserRole / OrgRole axes and the @repo/auth/guards boundary.
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.