Skip to content

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:

  1. Each organization has its own URL — a default subdomain (acme.app.example.com) or a tenant-owned custom domain (app.acme.com).
  2. Each organization configures its own SSO (OIDC), with email/password as the fallback and a per-org toggle to enforce SSO.
  3. All tenant data, sessions, cookies, and JWTs are isolated per host and org.
  4. 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.

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.

System topology
Rendering diagram…

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.

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.

PhaseGoalOutcome
0 — Validation spikeProve the riskiest integrations (SSO schema, OIDC registration, custom-hostname lifecycle) against the real toolchainConfidence before building
A — Core multi-tenancyTenant resolution, per-tenant SSO, custom hostnames, scoped JWTs/sessionsMulti-tenant traffic works (closed beta)
B — Admin panel + web splitOperator worker behind CF Access, operator-led onboarding, two SPAs, per-tenant brandingProduction-ready onboarding
C — Architectural deepeningFive deep-module consolidations (tenancy, tokens, tenant ops, operator auth, hostname lifecycle)Coherent at the module boundary

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 Domain attribute, no crossSubDomainCookies. SameSite is 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.sessionVersion claim, 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: true globally.
  • Operator-on-tenant actions are audited twice — once globally, once tenant-scoped.
  • audit_logs is append-only at the database level, enforced by a Postgres trigger.

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