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
POST /api/auth/email-otp/send-verification-otpwith{ email, type: "sign-in" }— server mints a 6-digit OTP, sends it via the configured email provider, returns200.POST /api/auth/email-otp/sign-inwith{ 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
- After registering, the frontend can call
POST /api/v1/auth/resend-verificationwith{ email }to send the email. - The user receives a link
{BETTER_AUTH_URL}/api/auth/verify-email?token=<JWT>. - The client extracts the token from the URL and calls
POST /api/v1/auth/verify-emailwith{ token }. - The endpoint responds
{ userId }and the user'semailVerifiedfield becomestrue.
// 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-verificationreturns 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
| Var | Required | Default | Notes |
|---|---|---|---|
APP_URL | yes | http://localhost:3004 | Frontend origin. The browser signs clientDataJSON.origin with this URL during the ceremony; a mismatch makes verifyRegistration fail. |
RP_ID | no | hostname of APP_URL | Relying Party ID. Domain only (no scheme/port/path). In local dev this stays as "localhost". |
RP_NAME | no | APP_NAME | Human-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-optionsPOST /api/auth/passkey/verify-registrationPOST /api/auth/passkey/generate-authentication-optionsPOST /api/auth/passkey/verify-authenticationGET /api/auth/passkey/list-user-passkeysPOST /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()returnsSESSION_NOT_FRESHand the UI opens a re-auth dialog: re-enter password (auto-retriesaddPasskeyon success) or send a magic link (consume it, then click Add passkey again). Both paths reuse the existing/auth/sign-inand/auth/magic-link/sendendpoints — 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.comRP_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.origindoesn't match the configuredorigin, registration succeeds in the browser (the OS prompt completes) and then the server returnsFAILED_TO_VERIFY_REGISTRATION. This bit us in #153 dev — the fix was derivingoriginfromAPP_URL(the frontend), notBETTER_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
// → 204Constraints
- 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
AccountDeletionRequestrow survives the user deletion as a GDPR-compliance tombstone (FKON 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.