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
| 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 ({ enabled: false }); Cloudflare Turnstile when config.captcha.enabled and TURNSTILE_SECRET_KEY are both set |
| Rate limiting | On by default, backed by Redis; 300 req/min global plus tighter per-route caps |
| Audit + notifications | Each sign-in writes a LOGIN_SUCCESS audit entry; revoking a device fires notifySessionRevoked |
| Browser client | apps/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.
| 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 |
/device/code, /device/approve, /device/deny | 10 |
/get-session, /organization/list, /organization/get-full-organization | 1000 |
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.
hooks: composeAuthHooks(shippedAuthHooks(), {
before: async (ctx) => {
/* your handler */
},
after: async (ctx) => {
/* your handler */
}
});| Your handler returns | Effect |
|---|---|
| nothing | The 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
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 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.
Internationalization
Translate the app from one shared @repo/i18n message store consumed by next-intl, with locale-prefixed routing and an auto-generated translation pipeline.
Social OAuth
Render and gate one-click social sign-in buttons via config.auth.socialProviders and per-provider OAuth credentials.