SaaS Starter

Authentication

BetterAuth with magic link, Google OAuth, organizations and RBAC.

Why BetterAuth

NextAuth is too heavy for our setup (Express + Next standalone). BetterAuth: TypeScript-first, schema in code, composable plugins, official Prisma adapter.

Setup

Required variables (apps/server/.env):

BETTER_AUTH_SECRET=<openssl rand -base64 32>
BETTER_AUTH_URL=http://localhost:3005
GOOGLE_CLIENT_ID=  # optional
GOOGLE_CLIENT_SECRET=

Enabled methods

  • Email + password with email verification
  • Magic link (Resend)
  • Email OTP — 6-digit passwordless code (mibio onboarding default)
  • Google OAuth
  • Passkeys (WebAuthn) — Touch-ID / Windows Hello / hardware keys
  • Sessions persisted in Postgres

Passwordless OTP

Used by the mibio onboarding (/register → /register/verify). Reduces signup friction to "type your email, type the 6 digits, you're in" — no password to remember.

Flow

  1. POST /api/auth/email-otp/send-verification-otp with { email, type: "sign-in" } — server mints a 6-digit OTP, sends it via the configured email provider, returns 200.
  2. POST /api/auth/email-otp/sign-in with { email, otp } — server verifies the code, returns the user + sets the session cookie. The user is now authenticated.

Codes are 6 digits, expire in 10 minutes. The plugin is registered as emailOTP in better-auth.adapter.ts; delivery is wired through the same emailProvider as magic-link and password-reset (no-op LogEmailProvider in dev when SMTP/Resend is unconfigured — the code is logged to stdout).

Email verification

Email verification is enabled but not required to sign in (requireEmailVerification: false). The emailVerified field shows up on /api/v1/auth/me.

Flow

  1. After registering, the frontend can call POST /api/v1/auth/resend-verification with { email } to send the email.
  2. The user receives a link {BETTER_AUTH_URL}/api/auth/verify-email?token=<JWT>.
  3. The client extracts the token from the URL and calls POST /api/v1/auth/verify-email with { token }.
  4. The endpoint responds { userId } and the user's emailVerified field becomes true.
// Resend
POST /api/v1/auth/resend-verification
{ "email": "[email protected]" }
// → 200 { "ok": true } — always, even if the email is already verified (no-enumeration)

// Verify
POST /api/v1/auth/verify-email
{ "token": "<JWT from email URL>" }
// → 200 { "userId": "..." }  |  422 if the token is invalid

[!NOTE] resend-verification returns 200 even if the email is already verified or doesn't exist, for security (no-enumeration). The email is only actually sent if the address exists and is not yet verified.

Passkeys (WebAuthn)

Passkeys let a user register a hardware-backed credential (Touch-ID, Windows Hello, security key) and sign in with it instead of a password. Implemented with @better-auth/passkey.

Environment variables

VarRequiredDefaultNotes
APP_URLyeshttp://localhost:3004Frontend origin. The browser signs clientDataJSON.origin with this URL during the ceremony; a mismatch makes verifyRegistration fail.
RP_IDnohostname of APP_URLRelying Party ID. Domain only (no scheme/port/path). In local dev this stays as "localhost".
RP_NAMEnoAPP_NAMEHuman-readable string shown in the OS prompt ("Sign in to {RP_NAME}").

Endpoints

The BetterAuth catch-all at /api/auth/* exposes the full plugin surface — no wrapped controllers:

  • POST /api/auth/passkey/generate-register-options
  • POST /api/auth/passkey/verify-registration
  • POST /api/auth/passkey/generate-authentication-options
  • POST /api/auth/passkey/verify-authentication
  • GET /api/auth/passkey/list-user-passkeys
  • POST /api/auth/passkey/delete-passkey

All of them go through authIpLimiter (per-IP rate-limit, same tier as the rest of /auth/*).

Client

import { authClient } from '@/lib/auth-client';

// Register (triggers OS prompt)
await authClient.passkey.addPasskey({ name: 'MacBook Touch-ID' });

// List
const { data } = await authClient.passkey.listUserPasskeys();

// Revoke
await authClient.passkey.deletePasskey({ id });

// Sign in (triggers OS prompt)
await authClient.signIn.passkey();

The UI lives at /settings/security (list + add + revoke) and the sign-in button is on /login.

[!WARNING] To add a passkey, BetterAuth requires a "fresh" session (default: < 24 hours). If the user has been signed in longer, addPasskey() returns SESSION_NOT_FRESH and the UI opens a re-auth dialog: re-enter password (auto-retries addPasskey on success) or send a magic link (consume it, then click Add passkey again). Both paths reuse the existing /auth/sign-in and /auth/magic-link/send endpoints — no custom re-auth route.

[!NOTE] The WebAuthn ceremony cannot be emulated in headless CI. The server tests cover plugin wiring + rate-limit; flow correctness is validated by a human on every PR that touches it (Touch-ID Mac / Windows Hello / etc.).

Deploying passkeys to production

The defaults work for localhost because the browser, the API and the configured RP_ID collapse to a single hostname. In production you have to think about it.

Single-host deploy. Frontend and API on the same registrable domain (e.g. https://example.com for both):

APP_URL=https://example.com
# RP_ID defaults to "example.com" (hostname of APP_URL) — leave unset.

Split-host deploy. Frontend on https://app.example.com, API on https://api.example.com:

APP_URL=https://app.example.com
RP_ID=example.com

RP_ID MUST be the registrable domain (eTLD+1), not the frontend's hostname — otherwise the credential created on app.example.com cannot be presented on any other subdomain. The browser still signs clientDataJSON.origin with APP_URL (the actual page origin), which the BetterAuth passkey({ origin }) config validates server-side.

[!WARNING] If clientDataJSON.origin doesn't match the configured origin, registration succeeds in the browser (the OS prompt completes) and then the server returns FAILED_TO_VERIFY_REGISTRATION. This bit us in #153 dev — the fix was deriving origin from APP_URL (the frontend), not BETTER_AUTH_URL (the API).

Organizations

Multi-tenancy via membership. A user can belong to several organizations with different roles in each.

type MembershipRole = 'owner' | 'admin' | 'member';

Switching the active organization = swapping the cookie context (active-org). Every dashboard query filters by organizationId.

RBAC

Permissions catalog in @app/shared/permissions:

export const PERMISSIONS = {
  ORG_MEMBERS_READ: 'org:members:read',
  ORG_MEMBERS_WRITE: 'org:members:write',
  BILLING_READ: 'billing:read',
  BILLING_WRITE: 'billing:write',
  // ...
} as const;

Role → permissions mapping in ROLE_TO_PERMISSIONS. Owner = all, admin = nearly all, member = mostly read.

The requirePermission(perm) middleware validates on every request:

router.post('/billing', requirePermission(PERMISSIONS.BILLING_WRITE), handler);

Account deletion (GDPR)

Account deletion is asynchronous: the system creates a pending request, the BullMQ worker processes it after the grace period and cascades the user removal.

Grace period

Controlled by the DELETION_GRACE_DAYS env var (default: 30). During the grace period the user can cancel the request. For test/E2E environments use DELETION_GRACE_DAYS=0 to observe the deletion immediately.

Endpoints

// Request deletion (re-auth required)
POST /api/v1/me/deletion
{ "password": "...", "reason": "..." }
// → 202 { "requestId": "...", "scheduledFor": "ISO-8601", "graceDays": 30 }

// Read pending request
GET /api/v1/me/deletion
// → 200 { "pending": { "id": "...", "requestedAt": "...", "scheduledFor": "..." } | null }

// Cancel during grace period
DELETE /api/v1/me/deletion
// → 204

Constraints

  • The user must provide their current password (re-auth).
  • If the user is the sole owner of an organization, the worker rejects the deletion with a conflict error. They must transfer ownership or delete the organization first.
  • The AccountDeletionRequest row survives the user deletion as a GDPR-compliance tombstone (FK ON DELETE SET NULL).

Data export (GDPR portability)

Lets the user download a JSON with all of their data. The work is asynchronous (BullMQ).

Endpoints

// Request export
POST /api/v1/me/export
// → 202 { "requestId": "...", "status": "pending" }

// Poll status / download URL
GET /api/v1/me/export/:id
// → 200 {
//     "id": "...",
//     "status": "pending" | "ready" | "failed" | "expired",
//     "downloadUrl": "..." | null,
//     "expiresAt": "ISO-8601" | null,
//     "errorMessage": "..." | null
//   }

The client should poll GET /me/export/:id until status is ready or failed. The download link expires after 24 hours.

Export contents

The JSON includes: user, memberships, auditEntries, sessions, accountDeletionRequests, dataExportRequests, subscriptions. BetterAuth-internal tables (OAuth tokens, verifications) are excluded for security.

Dynamic roles (future)

Tracked in Tasky #98 — let each organization define custom roles ("Editor", "Reviewer") with permissions from the catalog. Today: hardcoded enum.

On this page