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 ·
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.jsoncIn 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.
| Requirement | Starting point | Upgrade condition |
|---|---|---|
| Many accounts share business code | Worker plus D1 tenant column | Scale or compliance requires stronger isolation |
| Subdomain per tenant | Wildcard DNS plus trusted hostname mapping | Customers need their own domains |
| Customer-owned domain | Cloudflare for SaaS custom hostname | Automated certificate and domain lifecycle is required |
| Execute customer or AI-generated code | Keep it out of the shared Worker | Use 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
Cost
Logs
Abuse
Verification and troubleshooting
| Symptom | Check first | Recovery |
|---|---|---|
401 unauthorized | .dev.vars and x-api-key match | Recopy the local template; never commit the value |
403 forbidden | The user/tenant pair exists in memberships | Seed locally; use an audited invitation flow in production |
409 project_quota_reached | PROJECT_LIMIT and the tenant row count | Remove demo rows or change trusted plan configuration |
D1_ERROR | Local migration is applied and the binding is DB | Reapply 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.
Connect TiDB from Workers: @tidbcloud/serverless over HTTP
Workers cannot open raw TCP connections; use @tidbcloud/serverless to reach TiDB Cloud Starter/Essential over HTTP.
Project five, content management CMS
Use D1 for content state, R2 for media, and a Worker for publication boundaries from draft to public page.