Multi-Tenant SaaS on Cloudflare
Most products start single-tenant: there’s one customer, so “the customer” is
implied everywhere — one set of users, one login page, one database where every row
belongs to the same company. Then you sell to a second customer, and a third. Now you
need many customers, each with their own URL (app.acme.com), their own
login (Acme’s employees sign in through Acme’s SSO), and their own data (Acme
can never see Globex’s rows). The structure looks like a small extension — add a
tenant_id column, right? — but the moment two customers share one deployment,
host resolution, CSRF rules, SSO registration, and custom-domain lifecycle all turn
into deliberate design problems with real security stakes. This course walks through
one complete, production-grade design for getting it right on Cloudflare Workers
(Cloudflare’s serverless platform that runs your code at the network edge, close to
users).
The end state is a SaaS where:
- Each organization has its own URL — a default subdomain (
acme.app.example.com) or a tenant-owned custom domain (app.acme.com). - Each organization configures its own SSO (OIDC), with email/password as the fallback and a per-org toggle to enforce SSO.
- All tenant data, sessions, cookies, and JWTs are isolated per host and org.
- Operators onboard tenants through a separate admin panel behind Cloudflare Access; tenant admins then manage their own members.
It’s built on a Worker-native stack: Better Auth for identity, Drizzle over PostgreSQL via Hyperdrive (Hyperdrive is Cloudflare’s Postgres connection pooler, which lets edge Workers reuse database connections instead of opening a fresh one per request), Hono for the Workers, and TanStack Router/Query on the front end. There’s a single shared database with row-level org isolation — no per-tenant sharding.
Architecture at a glance
Section titled “Architecture at a glance”The system is three Workers plus two static SPAs, sharing one Postgres and one Better Auth instance. The boundaries encode the security model: tenants and operators never share a session, a worker, or a cookie domain.
flowchart TB subgraph Operator["Operator perimeter"] Access["admin.example.com<br/>Cloudflare Access"] end subgraph Tenant["Tenant perimeter"] Hosts["acme.app.example.com<br/>app.acme.com<br/>(Better Auth cookies)"] end Access --> Admin["apps/admin<br/>+ apps/admin-ui (static)"] Hosts --> Server["apps/server<br/>+ apps/app (static, via binding)"] Admin -->|"service bindings"| Auth["apps/auth<br/>(Better Auth)"] Server -->|"service binding"| Auth Auth --> DB["PostgreSQL via Hyperdrive"]
Five deployable units: three Hono Workers (apps/auth, apps/server, apps/admin)
and two static SPAs (apps/app, apps/admin-ui) served through Workers Static
Assets. The admin SPA ships with the admin worker; the tenant SPA is served by
apps/server via a service binding, so custom-domain visitors get the same assets.
The full topology and routing rules are in
Architecture & Topology.
How the work is structured
Section titled “How the work is structured”The design is delivered in four phases, each independently deployable. Phase 0 de-risks the integration points that depend on current Better Auth and Cloudflare behavior; Phase A puts the tenancy boundary in place from day one; Phase B adds the operator panel and the web split; Phase C finishes the architectural consolidations.
| Phase | Goal | Outcome |
|---|---|---|
| 0 — Validation spike | Prove the riskiest integrations (SSO schema, OIDC registration, custom-hostname lifecycle) against the real toolchain | Confidence before building |
| A — Core multi-tenancy | Tenant resolution, per-tenant SSO, custom hostnames, scoped JWTs/sessions | Multi-tenant traffic works (closed beta) |
| B — Admin panel + web split | Operator worker behind CF Access, operator-led onboarding, two SPAs, per-tenant branding | Production-ready onboarding |
| C — Architectural deepening | Five deep-module consolidations (tenancy, tokens, tenant ops, operator auth, hostname lifecycle) | Coherent at the module boundary |
The chapters
Section titled “The chapters”Operating principles
Section titled “Operating principles”These invariants hold across the whole design — they’re the lens for every chapter:
- Three workers + two static SPAs. New domains get new modules, not new workers.
- Per-host cookies. No
Domainattribute, nocrossSubDomainCookies.SameSiteis defense in depth, not the isolation boundary. - CSRF and origin checks are first-class. Sibling tenant subdomains are same-site
by browser rules, so isolation depends on host-only cookies plus explicit
Origin/CSRF enforcement on every state-changing endpoint. - The server↔auth boundary is sanitized. Forwarded host/proto headers are stripped or overwritten before any call into the auth worker.
- Per-tenant JWTs. Every token is scoped to one host with an
org.sessionVersionclaim, and verifiers check all five invariants. - Self-serve sign-up is off. Every tenant is operator-created; every tenant user
arrives via an invitation.
disableSignUp: trueglobally. - Operator-on-tenant actions are audited twice — once globally, once tenant-scoped.
audit_logsis append-only at the database level, enforced by a Postgres trigger.
Non-goals (v1)
Section titled “Non-goals (v1)”Deliberately out of scope for the first version: SAML 2.0 (the plugin supports it; only the onboarding UI is deferred), cross-tenant SSO or shared cookies, apex tenant domains (needs Cloudflare Enterprise), per-tenant DB sharding, per-tenant branded transactional email, operator impersonation (“view as tenant admin”, deferred to v2), and slug renaming (operationally heavy because of IdP-registered SSO callbacks).