Orange Book
End-to-end projects

Project four, multi-tenant SaaS foundation

Complete a minimum SaaS loop with a shared Worker, D1 tenant boundaries, quotas, and optional customer domains.

Edited and verified by Orange Book Editorial Team ·

PROJECT 04INTERMEDIATEabout 60 minutesOutcome: a tenant-isolated minimum SaaS

Completion criteria

A user joins a tenant and creates a record; another tenant cannot read or mutate it; writes require authentication and respect quota; customer domains and customer-code execution are explicit later capabilities, not blockers for the first release.

Prerequisites and companion example

You need Node.js 22, pnpm, and the repository dependencies. The example lives in examples/multitenant-saas and uses local D1 only. Copy the local secret, apply the migration, and seed two demo memberships:

cp examples/multitenant-saas/.dev.vars.example examples/multitenant-saas/.dev.vars
pnpm wrangler d1 migrations apply orange-book-saas --local --config examples/multitenant-saas/wrangler.jsonc
pnpm wrangler d1 execute orange-book-saas --local --file examples/multitenant-saas/seed.local.sql --config examples/multitenant-saas/wrangler.jsonc
pnpm wrangler dev --config examples/multitenant-saas/wrangler.jsonc

In another terminal, run node --test examples/multitenant-saas/test/index.test.mjs; five tests should pass. Use the README curl request to create a project. Expect status 201 and tenantId: tenant-a. A nonexistent membership must return 403; exceeding PROJECT_LIMIT must return 409 project_quota_reached.

Example authentication boundary

API_KEY and x-user-id only demonstrate a server-side membership lookup. Replace them with a real session or signed identity in a public SaaS, and never ship the shared key to browser code.

Choose the correct multi-tenant model

A normal SaaS does not need Workers for Platforms first. Start with one shared Worker, derive tenantId from trusted authentication, and bind a tenant predicate in every D1 query. Evaluate Workers for Platforms only when the product executes untrusted customer or AI-generated code and needs a separate Worker, CPU/subrequest limits, and dispatch namespace per customer.

RequirementStarting pointUpgrade condition
Many accounts share business codeWorker plus D1 tenant columnScale or compliance requires stronger isolation
Subdomain per tenantWildcard DNS plus trusted hostname mappingCustomers need their own domains
Customer-owned domainCloudflare for SaaS custom hostnameAutomated certificate and domain lifecycle is required
Execute customer or AI-generated codeKeep it out of the shared WorkerUse Workers for Platforms isolation

Minimum data boundary

Every tenant business table includes tenant_id; uniqueness and indexes also account for the tenant. Authentication resolves a user, then membership resolves a tenant. A browser-provided x-tenant-id can be a selection hint, but it cannot authorize access by itself.

const { userId, tenantId } = await requireMembership(request, env);

const result = await env.DB.prepare(
  `SELECT id, name, created_at
   FROM projects
   WHERE tenant_id = ?1 AND id = ?2`
).bind(tenantId, projectId).first();

Reads, updates, and deletes all include tenant_id. Returning 404 avoids revealing whether another tenant's object exists. The real authentication implementation depends on the project; the helper in this example is an explicit boundary, not an assumed API.

Build the core loop

Create organization and membership records

Create tenants, users, memberships, and one business table. Invite and join operations use a one-time token, expiry, and idempotent handling.

Protect every write

The Worker verifies the session, member role, and tenant predicate before writing. Use prepared statements and explicit bounds for body, strings, list size, and uploads.

Record business quota

Keep plan quota in trusted application data and update it audibly around writes. WAF Rate Limiting protects network abuse but cannot replace exact tenant billing or allowance.

Verify cross-tenant failure

Create tenants A and B. Create a record as A, then attempt read, update, and delete as B. Every attempt returns a stable denial. Logs include request ID and internal reason without exposing the other tenant's data.

Customer domain boundary

Cloudflare for SaaS can connect customer custom hostnames to the platform and manage certificate and routing lifecycle. A CNAME alone is not production evidence. Check hostname and SSL status, validation method, fallback origin, and retry behavior. If tenant.example.com is enough today, complete subdomains under your own zone before planning customer domains separately.

A hostname is not tenant identity

A hostname routes to a candidate tenant. The server still maps it to a trusted tenant record and authorizes the signed-in user's membership. Never use an arbitrary Host header as the tenant ID directly.

Security, cost, and observation

Secrets

Session signing, third-party keys, and management tokens stay in server bindings.

Cost

Track request, D1 operation, R2 object, and background-job business usage by tenant.

Logs

Correlate request, an internal non-reversible tenant identifier, version, and error code without sensitive body content.

Abuse

Protect login, invite, export, and expensive APIs with separate Turnstile/rate controls and application quotas.

Verification and troubleshooting

SymptomCheck firstRecovery
401 unauthorized.dev.vars and x-api-key matchRecopy the local template; never commit the value
403 forbiddenThe user/tenant pair exists in membershipsSeed locally; use an audited invitation flow in production
409 project_quota_reachedPROJECT_LIMIT and the tenant row countRemove demo rows or change trusted plan configuration
D1_ERRORLocal migration is applied and the binding is DBReapply the local migration and restart Wrangler

Remote boundary and rollback

Creating D1, applying a remote migration, configuring custom hostnames, enabling Workers for Platforms, or deploying production policy changes Cloudflare state. This tutorial does not run those actions. Code rollback does not remove a new schema or restore changed customer domains. Use compatible migrations and preserve hostname status and retry records. On an authorization risk, stop writes and disable the affected route before restoring stable code and auditing affected tenants.

Next: establish the website and application defense baseline.

Primary sources

Did this page help you complete your goal?

Beta feedback is generated in this browser and is never uploaded automatically.

On this page