ADR-018 — Platform auth on better-auth: Clerk retired, one auth library for staff, operators, and students
:::note Contenido en inglés Esta página del wiki se sincroniza desde la base de conocimiento en inglés y todavía no está traducida. :::
canonical· tech-lead · updated 2026-09-06 · source
- Status: Proposed — José ratifies on the effort #1527 PR review. Supersedes in part ADR-016 § Decision 1 "Clerk stays operator-only" (+ the "does not move operators off Clerk" scope boundary) and ADR-017 § 2 / § Auth design "operators authenticate with Clerk" (+ the Clerk half of its cookie/proxy design). Everything else in both ADRs stands.
- Date: 2026-09-06
- Owner: Tech Lead
- Related issues: effort #1527 (Phase 1 — this ADR is sub #1532; foundation sub #1656) · effort #1533 (Phase 2) · effort #1538 (Phase 3) · #1604 (Render/Cloudflare migration — the coordination checkpoint)
Context
- Two auth stacks run today, by design (ADR-016/017): Clerk for tedos staff + tenant
operators (
apps/admin,apps/console,apps/chat,apps/portal/operator/*) and better-auth for students (per-tenant instances insideapps/api, live in production). - What Clerk carries beyond sign-in: the tenant record (Clerk Organizations →
project.clerk_org_id,apps/api/src/auth/project-resolver.ts; org creation/deletion viaapps/api/src/auth/clerk.ts), role resolution (apps/api/src/auth/roles.tsmaps Clerk org roles →ProjectRole), the OAuth token vault for Google/GitHub connectors (packages/engine/src/clerkStore.ts), the API request guard (apps/api/src/plugins/auth.ts), the shared Next middleware + CSP (packages/auth), and the hosted dashboard (user lookup, session revoke, the@tuempresa.digitalstaff allowlist). - Forces: (1) two stacks = two mental models + two maintenance surfaces — ADR-016 accepted that trade deliberately for two populations; it no longer pays once one library can serve both; (2) Clerk satellite-domain pricing was rejected (ADR-017 amendment) and per-tenant custom domains already need OUR Host resolution; (3) the tenant record living in a vendor couples tenant provisioning to Clerk's API and its outages; (4) the hosted dashboard is a hidden operational dependency; (5) better-auth is already proven in production for students.
- Cost is NOT the driver. Clerk is $0 at this scale (ADR-016). Consistency and ownership are.
Decision (José, 2026-09-06)
Retire Clerk entirely. better-auth (self-hosted in the Fastify API, Drizzle + Postgres) becomes the ONE auth library. Two instance KINDS remain by design:
| Instance kind | Schema binding | Population | Plugins |
|---|---|---|---|
| Per-tenant student (ADR-016, unchanged) | each client's schema (getClientDb(projectId), auth-factory.ts) | students | phone-number + email-otp; no orgs |
| Platform (NEW) | the API's control schema (platform_* tables) | tedos staff now (Phase 1) · tenant operators Phase 3 | organization (the tenant/org record — replaces Clerk Organizations; project.clerk_org_id → better-auth organization id) · admin (user lookup, session revoke — console screens, #1531; replaces the hosted dashboard) · bearer (server seams keep sending Authorization: Bearer) |
- Staff sign-in = email OTP via the platform Resend account, invite-only (
disableSignUp; accounts created via theadminplugin or a boot-timePLATFORM_BOOTSTRAP_ADMIN_EMAILseed). Replaces Clerk's Google-only sign-in + the Dashboard domain allowlist. No Google SSO in Phase 1 (follow-up once a Google OAuth client is provisioned). - Transition = dual-accept. The API guard (
plugins/auth.ts) accepts a Clerk session OR a platform better-auth session until Phase 3 deletes Clerk. No big-bang. - Cookie posture. Phase 1: host-only per app — staff sign in per app (admin, console, chat).
Phase 3 decides cross-subdomain sharing under better-auth (
*.tuempresa.digital) — with the recorded checkpoint against #1604: re-check the domain/DNS topology before implementing #1540 (if the Render/Cloudflare cutover has landed or is imminent, the cookie plan is verified against the new topology first). - Tenant isolation is unchanged: students stay tenant data in per-tenant schemas (ADR-016/ADR-007) — the platform instance never holds student identities.
Phases (three efforts, sequential — each is the next one's dependency)
| Phase | Effort | Window | Scope |
|---|---|---|---|
| 1 | #1527 | 2026-09-04 → 2026-09-08 | Platform instance + dual-accept guard + @tedos/auth better-auth half (#1656); admin / console / chat off Clerk; admin-plugin console screens (#1531); this ADR (#1532) |
| 2 | #1533 | 2026-09-09 → 2026-09-14 | Tenant identity: organization plugin replaces ClerkOrgCreator/ClerkOrgDeleter; org migration script (written from scratch — better-auth's Clerk guide covers users, not Organizations); role resolution off Clerk; clerkStore.ts OAuth vault → better-auth account table |
| 3 | #1538 | 2026-09-15 → 2026-09-18 | apps/portal /operator/* off Clerk (proxy.ts gate + sign-in); cross-subdomain session design (#1540, after the #1604 checkpoint); delete every @clerk/* dep + packages/auth's Clerk half; ONE maintenance-window cutover (every operator re-authenticates once) |
Scope boundaries
- Does not change ADR-016's student-auth model, ADR-017's three-surface topology, or the
path-gated operator surface (
/operator/*on the tenant domain) — only WHICH library gates it. - Does not design the console admin screens (Designer) nor the org migration script (Phase 2).
Consequences
Positive
- One library, one stack (Fastify + Drizzle + Postgres), one mental model for every population.
- The tenant record lives in our Postgres — tenant provisioning no longer calls a vendor API.
- No hosted dashboard as an operational dependency; user lookup / session revoke are console screens.
- The cross-subdomain cookie and per-tenant custom-domain story are fully under our control.
Negative / trade-offs
- We own session / OTP / abuse machinery for staff and operators too (rate limits, lockout, delivery failures) — previously Clerk's.
- Per-app sign-in until Phase 3 (staff authenticate separately on admin, console, chat).
- The platform instance is a new security-review surface: control-schema binding,
admin-plugin privilege (who may create/revoke), bearer-token handling. - The Organizations migration script is bespoke (no upstream guide) — build + test against a seeded demo tenant before any real one exists (#1533).
Follow-ups required
- Security review of the platform instance (schema binding, admin privilege, bearer) before the Phase 3 cutover — blocking.
- Google SSO for staff once a Google OAuth client is provisioned (post-Phase 1).
- Delete
packages/core/src/providers/clerk-auth.ts(dead code — onlyapps/referencecalls it) and its"./providers/clerk-auth"subpath entry inpackages/core/package.json— the file is a public export today, so removing one without the other either dangles the export or leaves the file as unreachable dead weight. - The #1604 coordination checkpoint before #1540 (cross-subdomain sessions).
- Update the inline doc refs in
apps/portal/src/proxy.ts(cites ADR-016/017) when Phase 3 lands.
Alternatives considered
| Option | Why not |
|---|---|
| Keep the two-stack status quo (ADR-016/017) | Two mental models + two maintenance surfaces, tenant record in a vendor, hidden dashboard dependency — the deliberate trade no longer pays once one library serves both. |
| Clerk for students too (one stack = Clerk) | Rejected in ADR-016: SMS-only OTP (no WhatsApp), unverified per-tenant custom domains, satellite-domain pricing (ADR-017 amendment). |
| ONE better-auth instance for everyone incl. students | Students are tenant data in per-tenant schemas — the isolation mechanism IS the schema binding (ADR-016/ADR-007). A shared instance would collapse that boundary. |
| Another hosted IdP (WorkOS / Auth0 / Ory) | Same vendor coupling for the tenant record, new cost, no fit with the embedded Drizzle stack better-auth already runs in. |
References
- Efforts #1527 (Phase 1; subs #1656 foundation, #1531 console admin screens, #1532 this ADR) · #1533 (Phase 2) · #1538 (Phase 3; the #1604 checkpoint is recorded in its comment)
- ADR-016 — student auth on better-auth (unchanged); the "Clerk stays operator-only" clause is superseded here
- ADR-017 — three surfaces + path-gated operator access (unchanged); "operators authenticate with Clerk" is superseded here
- ADR-007 — schema-per-client isolation the student instances bind to
wiki/student-auth-and-portal.md— the shipped student half- Code:
apps/api/src/auth/student/auth-factory.ts·apps/api/src/plugins/auth.ts·apps/api/src/auth/clerk.ts·apps/api/src/auth/roles.ts·apps/api/src/auth/project-resolver.ts·packages/engine/src/clerkStore.ts·packages/auth/src/*·apps/portal/src/proxy.ts decisions/log.md(2026-09-06)