Authentication
Authenticate and authorize users with the @repo/auth Better Auth setup shared across the backend.
@repo/auth wraps Better Auth into a single export const auth (packages/auth/src/config.ts) served through the shared @repo/api backend. Auth is always on - individual capabilities are gated by @repo/config flags and by which OAuth env vars are set.
The Nuxt typed client lives at apps/web-nuxt/app/utils/auth.ts - createAuthClient from better-auth/vue with the organization, twoFactor, magicLink, lastLoginMethod, admin, and passkey plugins, plus the apiKey client plugin (conditional on config.apiKeys?.enabled) and the Stripe and Polar client plugins.
What you get
| Capability | Details |
|---|---|
| Email + password | Always on. 12-128 char passwords; requireEmailVerification: true blocks sign-in until the address is confirmed. |
| Magic link | Passwordless link expiring in 15 minutes (expiresIn: 900). |
| Sessions in Redis | Stored via secondaryStorage, with a 5-minute signed cookie cache (cookieCache.maxAge); lastActiveAt is stamped on session create and update. |
| Session revocation | Deleting a session evicts it from Redis and fires a notifySessionRevoked notification. |
| Self-service account deletion | deleteUser.enabled removes the avatar via @repo/storage and emits a user/deleted Inngest event. |
| Social OAuth | Google, GitHub, Facebook, Discord, and X buttons, each registering only when its env vars are set. |
| 2FA + passkeys | Authenticator-app TOTP, backup codes, and WebAuthn credentials. |
| Roles | Platform UserRole and per-membership OrgRole, enforced by server-side guards. |
| Rate limiting | On by default, backed by Redis. Default 300 req/min per IP, with tighter per-endpoint rules (e.g. 10/min on /sign-in/email, 5/min on /sign-up/email, 3/min on /sign-in/magic-link). |
packages/database/src/db/auth.ts is generated by Better Auth (pnpm auth:generate) - never edit it by hand. Add user columns under user.additionalFields in packages/auth/src/config.ts, then regenerate.
Account deletion cascade
Deletion runs beforeDelete (avatar cleanup) then afterDelete (Inngest event + admin notify), and Postgres foreign-key cascades clean up the rest.
| Removed on delete | Where |
|---|---|
| Avatar | @repo/storage via deleteFile |
| accounts, passkeys | DB cascade |
| members, invitations | DB cascade |
| twoFactors | DB cascade |
| Sessions | Evicted from Redis - not a Postgres table, not part of the cascade |
Explore
Social OAuth
Provider buttons gated by config.auth.socialProviders and env credentials.
Two-factor & passkeys
TOTP, backup codes, and WebAuthn bound to the API origin.
Authorization
UserRole, OrgRole, and the server-side guards that enforce them.
Frequently asked questions
Where are sessions stored?
In Redis through Better Auth secondaryStorage, with a 5-minute signed cookie cache (cookieCache.maxAge). There is no Postgres sessions table, so deleting a user evicts their sessions from Redis rather than via a DB cascade.
How do I add a column to the user record?
Add it under user.additionalFields in packages/auth/src/config.ts, then run pnpm auth:generate to regenerate packages/database/src/db/auth.ts. Never hand-edit the generated file.
How does the Nuxt client read the current session?
Use await authClient.useSession(ssrFetch) for SSR pages and gate user-specific UI inside <ClientOnly>. The ssrFetch helper forwards the cookie header and collapses same-origin absolute URLs to relative paths.
Is captcha part of this?
It ships disabled ({ enabled: false }) until you turn it on. The Better Auth captcha plugin (Cloudflare Turnstile) registers when config.captcha.enabled is true and TURNSTILE_SECRET_KEY is set, scoped to the /sign-up/email, /sign-in/email, /request-password-reset, and /sign-in/magic-link endpoints.