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
SessionsWritten to the sessions table and Redis (secondaryStorage); reads come from the cache, with a 5-minute cookie cache. lastActiveAt is stamped on session create and update
Account deletiondeleteUser.enabled: true - every request only emails a confirmation link to /delete-account; opening it runs the deletion flow below (refuse, cancel billing, erase data) and emits a user/deleted Inngest event. An administrator's removal runs the same flow
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, /delete-user, /organization/invite-member3
/two-factor/verify-otp, /two-factor/verify-totp, /two-factor/verify-backup-code5
/oauth2/register (the MCP server's open client registration)5
/device, /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? Every path - the emailed self-serve link and an administrator's removal - runs ONE flow (packages/auth/src/account-deletion.ts), in this order. Steps 1-2 and the Apple revoke run in a request hook ahead of the endpoint, once the request is authorized (the link's token is valid for the signed-in user and any password it carries is verified, or the caller is an administrator), because Better Auth deletes sessions and credentials before its adapter hook: a refusal there leaves the account, its sign-in and its link untouched. The adapter hook repeats steps 1-2 as a backstop and erases the data.

Refuse when the account is the only owner of an organization that still has other members, or pays for one another owner would then pay for (ACCOUNT_DELETION_SOLE_OWNER, naming those organizations). The user makes another member an owner and that member changes the user's role; nothing is decided for them.
Cancel billing for the account and its sole-member organizations - see Payments. A failed cancellation refuses the deletion (ACCOUNT_DELETION_BILLING).
Erase data: Apple grants, chat and run history (Mastra threads, messages, working memory, traces, workflow snapshots, scores, background tasks), notifications, stored files and the removed organizations' logo objects, billing rows, every organization where the user is the only member with everything in it, their own projects, API keys, device codes and verification values.
Delete the user, whose foreign keys take sessions, accounts, passkeys, memberships, two-factor rows and AI keys, settings, automations and integrations.

Kept on purpose: the billing ledger (billing_logs, owned_products, charge_reversals), signup_grant_marks, audit_logs and the payment provider's customer record - financial, tax, anti-abuse and security records. An administrator's bulk delete reports each refused account with its reason (sole_owner, outstanding_balance, billing).

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