Blueprint: a multi-tenant SaaS
Pages + Worker API + Access JWT + D1 (tenant_id on every row) + KV + Durable Objects + Web Analytics — wired into one secure multi-tenant blueprint.
What are we building?
A multi-tenant SaaS is one application that serves many separate customers (called tenants) from the same codebase and the same database. Think of an office building: one building, one front desk, but each company gets its own locked floor. Our job is to make sure Tenant A can never see Tenant B's data.
Think of it like an apartment building
Everyone shares the lobby, the elevators and the plumbing (shared infrastructure), but each tenant has a key that only opens their own apartment (tenant_id). The doorman (Access) checks ID at the entrance; the key (tenant_id) decides which door opens inside.
Here is the whole system on one page. A user opens the front-end (hosted on Pages), Cloudflare Access verifies who they are and hands the Worker a signed token (JWT). The Worker reads the tenant_id out of that token, checks a per-tenant rate limit, loads per-tenant config, and finally runs a database query that is scoped to that one tenant.
Who does what?
Each Cloudflare service in the blueprint has one clear job. Keeping responsibilities separate is what makes the system easy to reason about and secure.
Pages — front-end
Hosts the static web app (HTML/JS/CSS) on Cloudflare's global edge. It calls the Worker API with fetch and shows the results.
Worker — API
The brain. It runs the auth/tenant middleware, enforces limits, and is the only thing that talks to the database. The front-end never touches D1 directly.
Access — identity
The doorman. It logs the user in (Google, GitHub, email PIN) and hands the Worker a signed JWT carrying claims like email and tenant_id.
D1 — SQL database
One shared SQLite database for everyone. Every tenant-owned table carries a tenant_id column, and every query filters by it.
KV — per-tenant config
A fast key-value store for small, read-heavy data: each tenant's feature flags, plan limits and cached settings, keyed by tenant_id.
Durable Objects — rate limit
One tiny stateful instance per tenant, named by tenant_id. It counts requests so one noisy tenant can't slow down everyone else.
Web Analytics — insight
Privacy-first, cookie-free traffic stats for the front-end, so you can watch usage and performance without slowing the page down.
How data isolation actually works
We use the simplest, most common pattern: a shared database where every row is tagged with its tenant_id. There are no separate databases per customer. Isolation comes from one iron rule — every single query adds WHERE tenant_id = ?, and the Worker fills that value in from the verified JWT, never from anything the user typed.
The data model
Three tables show the pattern: tenants (the customers), users (people who belong to a tenant), and projects (an example resource). Notice that every table except tenants carries a tenant_id foreign key — that column is what makes isolation possible.
Why tenant_id is a foreign key
Making tenant_id a foreign key to tenants(id) means the database itself refuses to create a row pointing at a tenant that doesn't exist. It is a cheap, built-in safety net on top of your application logic.
One authenticated request, step by step
Follow a single API call from the browser all the way to the database and back. The key moment is in the middle: the Worker resolves tenant_id from the JWT and then scopes the SQL query to that tenant.
Never trust the client for tenant_id
The tenant_id must come from the signed JWT that Access verified — not from a URL parameter, request body, or header the user controls. If you let the client pick the tenant_id, anyone could just ask for another tenant's data.
Build it: middleware + tenant-scoped queries
Here is the whole thing in real, runnable code: the SQL schema, the front-end, the auth/tenant middleware, the per-tenant rate limiter, the tenant-scoped query, and the wrangler config that binds it all together.
1. Create the D1 schema (tenant_id everywhere)
Every tenant-owned table gets a tenant_id column with a foreign key, plus an index so scoped queries stay fast as data grows.
-- Every tenant-owned table carries tenant_id. Index it for fast, scoped queries. CREATE TABLE tenants ( id TEXT PRIMARY KEY, name TEXT NOT NULL, plan TEXT NOT NULL DEFAULT 'free' ); CREATE TABLE users ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL REFERENCES tenants(id), email TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member' ); CREATE TABLE projects ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL REFERENCES tenants(id), owner_id TEXT NOT NULL REFERENCES users(id), name TEXT NOT NULL ); -- Index tenant_id so WHERE tenant_id = ? stays fast as rows grow. CREATE INDEX idx_projects_tenant ON projects(tenant_id); CREATE INDEX idx_users_tenant ON users(tenant_id);2. Front-end (Pages) calls the API
The page just calls /api/projects with fetch. Access has already verified the user, so the auth cookie rides along automatically — the front-end never sees or sends the tenant_id.
// public/app.js - served by Cloudflare Pages // Access already verified the user; the auth cookie rides along automatically. async function loadProjects() { const res = await fetch('/api/projects', { headers: { 'Accept': 'application/json' }, credentials: 'include' }); if (!res.ok) { document.getElementById('status').textContent = 'Error ' + res.status; return; } const data = await res.json(); document.getElementById('list').innerHTML = data.projects.map(p => `<li>${p.name}</li>`).join(''); } loadProjects();3. Auth + tenant middleware
Read the JWT that Access already verified and pull the tenant_id claim out of it. This is the one place a tenant is decided — from the token, never from user input.
// src/index.js - Worker API entry export { RateLimiter } from './rate-limiter.js'; // Auth + tenant middleware: read the JWT Access already verified, // then pull the tenant_id claim out of it. function resolveTenant(request) { const jwt = request.headers.get('Cf-Access-Jwt-Assertion'); if (!jwt) return null; const parts = jwt.split('.'); if (parts.length !== 3) return null; const json = atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')); const claims = JSON.parse(json); if (!claims.tenant_id) return null; return { tenantId: claims.tenant_id, email: claims.email }; }4. Per-tenant rate limiter (Durable Object)
One Durable Object instance per tenant (named by tenant_id) keeps an isolated counter. A 100-request-per-minute window means one busy tenant can never use up another tenant's budget.
// src/rate-limiter.js - one Durable Object instance per tenant. export class RateLimiter { constructor(state) { this.state = state; } async fetch() { const now = Date.now(); const windowMs = 60000; // 1-minute window const limit = 100; // 100 requests / minute / tenant let w = (await this.state.storage.get('w')) || { start: now, count: 0 }; if (now - w.start > windowMs) w = { start: now, count: 0 }; w.count += 1; await this.state.storage.put('w', w); const ok = w.count <= limit; return new Response(ok ? 'ok' : 'limited', { status: ok ? 200 : 429 }); } }5. The request pipeline + tenant-scoped query
Tie it together: resolve the tenant, check its rate limit, read its KV config, then run the D1 query with WHERE tenant_id = ?, binding the value from the JWT. That bind is what guarantees isolation.
// src/index.js (continued) - the request pipeline export default { async fetch(request, env) { // 1) Who is this, and which tenant do they belong to? const tenant = resolveTenant(request); if (!tenant) return new Response('Unauthorized', { status: 401 }); // 2) Per-tenant rate limit: one Durable Object named after the tenant. const stub = env.RATE_LIMITER.get(env.RATE_LIMITER.idFromName(tenant.tenantId)); const rl = await stub.fetch('https://do/check'); if (rl.status === 429) return new Response('Too Many Requests', { status: 429 }); // 3) Per-tenant config from KV (cheap, cached at the edge). const raw = await env.TENANT_KV.get('cfg:' + tenant.tenantId); const cfg = raw ? JSON.parse(raw) : { maxProjects: 100 }; // 4) Tenant-scoped query: EVERY statement filters WHERE tenant_id = ? const { results } = await env.DB .prepare('SELECT id, name FROM projects WHERE tenant_id = ? ORDER BY name') .bind(tenant.tenantId) .all(); return Response.json({ tenant: tenant.tenantId, config: cfg, projects: results }); } };6. Wire the bindings in wrangler
Declare the D1, KV and Durable Object bindings so the Worker can reach them as env.DB, env.TENANT_KV and env.RATE_LIMITER. The migration registers the Durable Object class.
// wrangler.jsonc { "name": "saas-api", "main": "src/index.js", "compatibility_date": "2025-01-01", "d1_databases": [ { "binding": "DB", "database_name": "saas", "database_id": "<your-d1-id>" } ], "kv_namespaces": [ { "binding": "TENANT_KV", "id": "<your-kv-id>" } ], "durable_objects": { "bindings": [{ "name": "RATE_LIMITER", "class_name": "RateLimiter" }] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["RateLimiter"] } ] }
# create the database, the KV namespace, load the schema, then deploy
npx wrangler d1 create saas
npx wrangler kv namespace create TENANT_KV
npx wrangler d1 execute saas --remote --file=./schema.sql
npx wrangler deployKey concepts
Multi-tenancy
One running app and one database serving many customers (tenants). Cheaper and simpler to operate than spinning up a separate copy per customer.
Data isolation
The guarantee that one tenant can never read or write another tenant's data. Here it is enforced by always filtering on tenant_id.
tenant_id
A column on every tenant-owned row that says which tenant it belongs to. The Worker sets it from the JWT — never from client input.
JWT claims
A JWT is a signed token; the data inside (email, tenant_id) are its claims. Because Access signs it, the Worker can trust those values.
Per-tenant rate limiting
Each tenant gets its own request budget via a dedicated Durable Object, so a single heavy tenant cannot degrade service for the others.
Per-tenant config in KV
Small per-tenant settings (plan, feature flags) live in KV keyed by tenant_id — read in microseconds at the edge, no database round-trip.
Pitfalls & billing
Make the safe path the only path
Wrap D1 access in a tiny helper that always takes a tenantId argument and always injects WHERE tenant_id = ?. If no query can run without a tenant, you can't forget the filter by accident.
- Forgetting WHERE tenant_id = ? on even one query leaks data across tenants — review every SQL statement for it.
- Always .bind() the tenant_id as a parameter; never string-concatenate it into SQL (that invites SQL injection).
- Index tenant_id on every tenant table, or scoped queries get slow as tenants accumulate rows.
- Trust tenant_id only from the verified JWT, not from URLs, bodies or headers the client controls.
- Name each rate-limit Durable Object by tenant_id so counters never bleed between tenants.
- D1, KV, Durable Objects and Workers all have generous free tiers; you pay per request/read/write as you scale — check current pricing in the docs.
Related products
menu_bookOfficial docsopen_in_new