Skip to main content

ADR-016 — Student auth (better-auth in the Fastify API) + the student portal (one multi-tenant `apps/portal`)

canonical · tech-lead · updated 2026-09-06 · source

  • Status: Accepted — superseded in part by ADR-018 (2026-09-06: operator/staff auth moves to better-auth; the "Clerk stays operator-only" clause and the "does not move operators off Clerk" boundary no longer hold — the student-auth decision itself is unchanged)
  • Date: 2026-07-03
  • Owner: Tech Lead
  • Related issues: #1156 (spike — provider research + owner decision) · effort #1131 (student credentials + portal) · #1131 sub-issue #1157 (this ADR); consumers ADR-015 (module/connector pattern), #1129 (student commerce backend)

Context

  • Effort #1131 ships student credentials and a student-facing portal (view courses, invoices, certificates). This needs two decisions the operator flagged as open at the close of spike #1156: which auth provider authenticates students, and what shape the student surface takes.
  • The student population is not the operator population. Operators (admin/console) already authenticate with Clerk. Students are the tenants' end-users — potentially many thousands per tenant, signing up gradually, identified phone-first (WhatsApp is the PyME channel), across many tenants served from one platform.
  • Spike #1156 evaluated per-tenant self-hosted Supabase Auth, Clerk-for-students, Ory Kratos, Keycloak/Zitadel, and a bespoke build against an in-process library. Full rationale + sources are in the #1156 research brief; the decision (José, 2026-07-03) is recorded there and ratified here.
  • The forces: (1) WhatsApp-first OTP — Clerk is SMS-only, no WhatsApp channel; (2) per-tenant custom domains — unverified in Clerk's satellite-domain model; (3) multi-tenancy at PyME scale without an ops-toil-per-tenant tax; (4) fit with the existing Fastify + Drizzle + Postgres stack (ADR-001) and the ADR-015 connector-injection pattern; (5) cost is a wash at this scale — Clerk's MRU pricing is now $0 to 50k retained users, so cost is not the tiebreaker.

Decision

1. Student auth = better-auth + Drizzle adapter, embedded in the Fastify API (apps/api)

Students authenticate against our own Postgres via better-auth running in-process inside the Fastify API — no separate auth service, no per-tenant IdP container.

  • Tenant-scoped via a thin tenant-aware Drizzle adapter wrapper. A small wrapper appends tenantId scoping to the adapter's queries (the documented community multi-tenant pattern, not the B2B organization plugin — students need no teams/roles). This wrapper is the ONE piece not maintained upstream and therefore the load-bearing custom surface.
  • Phone OTP via better-auth's phone-number plugin. sendOTP delivery is injected via the WhatsApp connector, following ADR-015's connector-injection pattern — the auth code declares the need and receives a connector client from the host at runtime; it never imports the engine (where OAuth/secrets/tenant accounts live). Email is secondary via the core email plugin.
  • Auth tables live in the per-client schema. They are provisioned through packages/domain/src/provisioning.ts templateDdl — the established client-schema migration mechanism (raw, idempotent DDL). drizzle-kit governs only the control schema; per-client schemas are DDL-provisioned, so the auth tables follow that same path, not a drizzle-kit migration.
  • Clerk stays operator-only. admin + console keep Clerk. Two auth systems by design — two different populations (operators vs. students), two different identity models (email/SSO vs. phone-first OTP), no shared session.

2. Student portal = ONE multi-tenant Next.js app apps/portal

The student surface is a single multi-tenant Next.js app that resolves the tenant by hostname (subdomain now, per-tenant custom domains later — same Host-resolution model as the client-configuration framework).

  • Supersedes the earlier idea of placing the student surface inside apps/clients/comprender. Comprender keeps its white-label operator surface; the student portal is its own app.
  • One app, one deploy, serving every tenant — not N apps and not N deploys.
  • Total monorepo package reuse under ADR-012's boundaries:
    • @tedos/ui (neutral foundation) + a per-tenant .client token scope for the tenant's skin.
    • @tedos/core for shared logic (data seams, view-models, hooks).
    • @tedos/api-client as the typed data-fetching seam to the API.
  • Thin-app architecture (ADR-008 / react-style-guidelines.md) makes a new Next app nearly free — the app is a shell over the packages, so the incremental cost of a dedicated portal app is small and the isolation gain (separate population, separate skin, separate deploy target) is real.
  • Follows the same import boundaries as every other app (ADR-012): it consumes generic primitives from @tedos/ui, never @tedos/ui/admin*; it does not reach into another app.

Scope boundaries

  • This ADR does not pick the WhatsApp delivery vendor (Twilio Verify vs. WhatsApp Business API direct) — a cost/volume follow-up, see #1156 open questions.
  • It does not design the portal's screens (Designer) nor specify the credentials data model (Backend) — it fixes the auth provider + app topology only.
  • It does not move operators off Clerk.

Consequences

Positive

  • One auth stack that fits the stack we already run (Fastify + Drizzle + Postgres); no separate stateful IdP service, no per-tenant container fleet to patch/rotate/monitor.
  • WhatsApp-first OTP is reachable (BYO sendOTP over the WhatsApp connector) — the phone-first requirement Clerk cannot meet.
  • Full control of per-tenant custom domains (Host resolution is ours), unblocking the white-label domain story without a vendor's satellite-domain limitation.
  • The portal is one deploy for every tenant; new tenants are config, not new apps.

Negative / trade-offs

  • The tenant-aware Drizzle adapter wrapper is the one non-upstream piece and MUST get its own security review before production — a scoped PR-level review of the adapter code specifically (tenant isolation, query scoping, no cross-tenant leakage). This is the highest-risk surface of the decision.
  • Two auth systems (Clerk for operators, better-auth for students) means two mental models and two maintenance surfaces — accepted deliberately for the two populations.
  • better-auth is a young library; mitigated by strong adoption signal and the fact it is an npm dependency inside code we own (forkable/vendorable), not a hosted lock-in.
  • SMS/WhatsApp delivery cost is a real per-message line regardless of provider — not avoided, just routable via the cheaper WhatsApp channel.

Follow-ups required

  • Security review pass on the tenant-aware Drizzle adapter wrapper before production (blocking).
  • Add whatsapp to the connector registry + a sendOTP connector-injection seam (ADR-015).
  • Provision the better-auth tables via packages/domain/src/provisioning.ts templateDdl.
  • Scaffold apps/portal (thin app over @tedos/ui + @tedos/core + @tedos/api-client) with Host-based tenant resolution.
  • Follow-up spike: WhatsApp delivery vendor (Twilio Verify vs. WhatsApp Business API direct) by volume/cost; per-tenant custom-domain provisioning at the proxy layer.

Alternatives considered

OptionWhy not
Per-tenant self-hosted Supabase Auth (GoTrue)Single-tenant by design; the multi-instance mode is an explicitly unsupported legacy Netlify-era feature. The only sane path is one container per tenant → cost scales 1:1 with tenant count (secrets, patches, monitoring), a separate Go service + schema that reuses none of the Drizzle stack.
Clerk for studentsNow free to 50k MRU (cost is a wash), but SMS-only OTP — no WhatsApp channel — and an unverified custom-domain-per-tenant story (satellite domains documented only for domains you own). The two tiebreakers both go the other way.
Ory KratosSingle-tenant by design (Ory's own guidance); true multi-tenancy forces a paid Enterprise License or Ory Network. Separate stateful service + own identity schema to run and sync.
Keycloak / ZitadelFull OIDC/SAML IdP surface a course/invoice/certificate portal doesn't need; Keycloak degrades past a few hundred realms, Zitadel CE is AGPL-3.0. Another stateful service + DB for a solo builder + agents to operate. Real fallback only if a tenant later demands enterprise IdP/SSO.
Bespoke (phone OTP + session table in our API)Reinvents solved session/token/OTP/rate-limit/abuse machinery — highest risk, longest time-to-first-login, 100% of the security burden ours. better-auth gives that machinery maintained upstream with ~20% custom code (the tenant wrapper).
Student portal inside apps/clients/comprenderMixes the student population into comprender's white-label operator surface; the earlier idea, superseded. A dedicated thin app is nearly free (ADR-008) and keeps populations, skins, and deploys separate.
N per-tenant portal appsMultiplies deploys and workspace entries for no isolation gain — one Host-resolving multi-tenant app + a per-tenant .client token scope re-skins per tenant with one deploy.

References

  • Spike + decision record: #1156 (research brief, addendum, and José's 2026-07-03 decision)
  • ADR-015 (@tedos/modules + the connector entity / injection pattern — the sendOTP seam follows it)
  • ADR-012 (.claude/rules/app-import-boundaries.md — the portal follows the same import rules)
  • ADR-008 (.claude/rules/react-style-guidelines.md — thin-app architecture makes the portal cheap)
  • ADR-001 (Fastify + Drizzle + Postgres — the stack better-auth embeds into)
  • packages/domain/src/provisioning.ts (templateDdl — the per-client schema migration mechanism)
  • wiki/client-configuration-framework.md (Host-based tenant resolution, one app serves every domain)
  • decisions/log.md (2026-07-03)