Saltar al contenido principal

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 inside apps/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 via apps/api/src/auth/clerk.ts), role resolution (apps/api/src/auth/roles.ts maps 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.digital staff 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 kindSchema bindingPopulationPlugins
Per-tenant student (ADR-016, unchanged)each client's schema (getClientDb(projectId), auth-factory.ts)studentsphone-number + email-otp; no orgs
Platform (NEW)the API's control schema (platform_* tables)tedos staff now (Phase 1) · tenant operators Phase 3organization (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 the admin plugin or a boot-time PLATFORM_BOOTSTRAP_ADMIN_EMAIL seed). 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)

PhaseEffortWindowScope
1#15272026-09-04 → 2026-09-08Platform instance + dual-accept guard + @tedos/auth better-auth half (#1656); admin / console / chat off Clerk; admin-plugin console screens (#1531); this ADR (#1532)
2#15332026-09-09 → 2026-09-14Tenant 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#15382026-09-15 → 2026-09-18apps/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 — only apps/reference calls it) and its "./providers/clerk-auth" subpath entry in packages/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

OptionWhy 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. studentsStudents 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)