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
tenantIdscoping to the adapter's queries (the documented community multi-tenant pattern, not the B2Borganizationplugin — 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-numberplugin.sendOTPdelivery 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.tstemplateDdl— the established client-schema migration mechanism (raw, idempotent DDL).drizzle-kitgoverns 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.clienttoken scope for the tenant's skin.@tedos/corefor shared logic (data seams, view-models, hooks).@tedos/api-clientas 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
sendOTPover 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
whatsappto the connector registry + asendOTPconnector-injection seam (ADR-015). - Provision the better-auth tables via
packages/domain/src/provisioning.tstemplateDdl. - 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
| Option | Why 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 students | Now 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 Kratos | Single-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 / Zitadel | Full 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/comprender | Mixes 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 apps | Multiplies 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 — thesendOTPseam 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)