sellIntegration
apartment

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.

1 DBShared database, many tenants
tenant_idOn every row
7Cloudflare services wired
100/minPer-tenant rate limit
insights

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.

apartment

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.

schemaSystem architecture

Browser (Pages)

Access: verify JWT

Worker API

Middleware: resolve tenant_id

Durable Object: per-tenant rate limit

KV: per-tenant config

D1 query: WHERE tenant_id = ?

Tenant-scoped data

Web Analytics

account_tree

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.

web

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.

dns

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.

badge

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.

database

D1 — SQL database

One shared SQLite database for everyone. Every tenant-owned table carries a tenant_id column, and every query filters by it.

bolt

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.

speed

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.

monitoring

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.

schemaShared table, isolated rows

tenant_id = A

tenant_id = B

filtered

filtered

Tenant A

Shared D1 table

Tenant B

A sees only A rows

B sees only B rows

schema

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.

schemaEntity relationships

has

owns

creates

TENANTS

text

id

PK

text

name

text

plan

USERS

text

id

PK

text

tenant_id

FK

text

email

text

role

PROJECTS

text

id

PK

text

tenant_id

FK

text

owner_id

FK

text

name

key

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.

swap_vert

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.

schemaAuthenticated request flow
"D1""KV""Durable Object""Worker API""Access""Browser""D1""KV""Durable Object""Worker API""Access""Browser"Request + JWTVerified JWTResolve tenant_idCheck tenant rate limitAllowedLoad tenant configConfigSELECT WHERE tenant_id = ?Tenant rows onlyJSON
gpp_maybe

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.

construction

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

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

    js
    // 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. 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.

    js
    // 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. 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.

    js
    // 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. 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.

    js
    // 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. 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.

    jsonc
    // 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"] }
      ]
    }
bashCreate resources and deploy
# 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 deploy
school

Key concepts

groups

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.

shield_lock

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.

fingerprint

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.

verified_user

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.

speed

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.

bolt

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.

tips_and_updates

Pitfalls & billing

checklist

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.