GenerateSaaS

Authentication

Authenticate users with the @repo/auth Better Auth instance - sessions, magic links, rate limits, and the client wired into the Next.js app.

@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.

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 ({ enabled: false }); Cloudflare Turnstile when config.captcha.enabled and TURNSTILE_SECRET_KEY are both set
Rate limitingOn by default, backed by Redis; 300 req/min global plus tighter per-route caps
Audit + notificationsEach sign-in writes a LOGIN_SUCCESS audit entry; revoking a device fires notifySessionRevoked
Browser clientapps/web/lib/auth-client.ts - createAuthClient from better-auth/react, wiring the org, 2FA, magic-link, last-login-method, admin, passkey, device-authorization, Stripe, and Polar plugins (plus API-key when config.apiKeys.enabled)

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

Budgets live in one table, packages/auth/src/rate-limits.ts, stored in Redis. The global window is 300 req / 60 s; sensitive routes tighten it, and 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
/device/code, /device/approve, /device/deny10
/get-session, /organization/list, /organization/get-full-organization1000

AUTH_RATE_LIMIT_MULTIPLIER scales every budget by a whole number in [1, 50], keeping the ratio between routes intact. Unset it is 1 - the table exactly as shipped; the e2e config already raises it, because a suite drives one address and lands every request in one bucket.

Raise, never remove. No value disables rate limiting: 0, an empty value, or anything unparseable stops the process at boot rather than resolving to something permissive. If production traffic shares one bucket, the client address is not resolving - fix that with Client IP.

Waitlist mode

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

Cross-subdomain cookies

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

Request hooks

Better Auth accepts ONE hooks.before and ONE hooks.after, and this boilerplate uses both. packages/auth/src/hooks.ts chains them: shippedAuthHooks() is what ships, and composeAuthHooks appends yours.

packages/auth/src/config.ts
hooks: composeAuthHooks(shippedAuthHooks(), {
  before: async (ctx) => {
    /* your handler */
  },
  after: async (ctx) => {
    /* your handler */
  }
});
Your handler returnsEffect
nothingThe request continues. What an observing hook should do.
{ context: {...} } (before)Patches the endpoint's input. The chain continues and patches deep-merge.
any other object (before)Answers the request. The endpoint and the remaining handlers are skipped.
a value (after)Replaces the response. Later handlers read it on ctx.context.returned.
throw new APIError(...)Refuses the request and stops the chain.

Pass a plain async function, never a createAuthMiddleware(...) result. A middleware called from inside another middleware returns Better Auth's { headers, response } envelope, and every endpoint then answers with it - 200, a body of {"headers":{}}, no session cookie, no error and no log line. composeAuthHooks throws at boot rather than let that ship.

Handlers run in argument order, and the shipped set refuses bad requests by throwing. A handler composed behind shippedAuthHooks() therefore never sees a refused request - put anything that must run on every request ahead of it.

Set cookies and headers from an after handler. Better Auth drops whatever a before handler sets, composed or not: it replaces the accumulated response headers with the endpoint's own once the route returns.

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 for 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 have no Postgres table, so they are evicted from Redis instead.

Where do I read the current user? Read session.user in a server component, or call useSession() from apps/web/lib/auth-client.ts in a client component. 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. The client infers it automatically via inferAdditionalFields.

On this page