Skip to content

tenant_id Enforcement Patterns

Implementation patterns for guaranteed tenant_id scoping across all data operations.

Prerequisites: PRIMITIVES.md, multi-tenant-isolation.md


// GOLDEN RULE: tenant_id ALWAYS from auth context, NEVER from request
∀ Query → WHERE tenant_id = auth.tenant_id
∀ Fact → fact.tenant_id = auth.tenant_id (forced)
∀ Entity → entity.tenant_id validated against auth.tenant_id

This is a ONE-WAY DOOR decision (Principle 2). If tenant_id enforcement is compromised, multi-tenant isolation fails catastrophically.


❌ Anti-Pattern 1: tenant_id from Request

Section titled “❌ Anti-Pattern 1: tenant_id from Request”
// WRONG: Attacker can set tenant_id to any value
async function getFacts(request: Request): Promise<Fact[]> {
const { tenant_id, type } = await request.json();
return db.query('SELECT * FROM facts WHERE tenant_id = ? AND type = ?', [tenant_id, type]);
}

Attack:

Terminal window
curl -X POST https://api.web1.co/facts \
-d '{"tenant_id": "tenant_victim", "type": "charge"}'

Attacker reads Tenant B’s data despite being authenticated as Tenant A.


❌ Anti-Pattern 2: Optional tenant_id Validation

Section titled “❌ Anti-Pattern 2: Optional tenant_id Validation”
// WRONG: Validation can be bypassed
async function updateEntity(authCtx: AuthContext, entityId: string, updates: Partial<Entity>): Promise<Entity> {
const entity = await db.queryOne('SELECT * FROM entities WHERE id = ?', [entityId]);
// BUG: Only validates if entity exists. Attacker can create entity_id that doesn't exist.
if (entity && entity.tenant_id !== authCtx.tenant_id) {
throw new ForbiddenError('Entity does not belong to tenant');
}
return db.update('UPDATE entities SET ... WHERE id = ?', [entityId]);
}

Attack: Attacker creates entity with entity_id from another tenant. No validation occurs.


❌ Anti-Pattern 3: Global Queries Without tenant_id

Section titled “❌ Anti-Pattern 3: Global Queries Without tenant_id”
// WRONG: Queries all tenants' data
async function searchContacts(authCtx: AuthContext, query: string): Promise<Contact[]> {
// BUG: Missing WHERE tenant_id = ?
return db.query('SELECT * FROM entities WHERE type = ? AND name LIKE ?', ['contact', `%${query}%`]);
}

Attack: Attacker searches for contacts across all tenants.


❌ Anti-Pattern 4: Trusting Entity Metadata

Section titled “❌ Anti-Pattern 4: Trusting Entity Metadata”
// WRONG: Entity metadata can be forged
async function appendFact(authCtx: AuthContext, fact: Fact): Promise<Fact> {
const entity = await getEntity(fact.entity_id);
// BUG: entity.tenant_id might be from another tenant if getEntity doesn't validate
fact.tenant_id = entity.tenant_id;
return accountDO.appendFact(fact);
}

Attack: Attacker supplies entity_id from another tenant. If getEntity doesn’t validate tenant_id, Fact is written to wrong tenant.


// CORRECT: tenant_id forced from auth, request value ignored
async function queryFacts(authCtx: AuthContext, filters: FactFilters): Promise<Fact[]> {
// filters.tenant_id is ignored even if provided
return db.query(`
SELECT * FROM facts
WHERE tenant_id = ?
AND type = ?
AND timestamp >= ?
`, [
authCtx.tenant_id, // Always from auth
filters.type,
filters.since
]);
}

Key: Request can send tenant_id, but it’s never used. Auth context is authoritative.


// CORRECT: Validate entity belongs to tenant before operating
async function getEntity(authCtx: AuthContext, entityId: string): Promise<Entity> {
const entity = await db.queryOne(`
SELECT * FROM entities
WHERE id = ? AND tenant_id = ?
`, [entityId, authCtx.tenant_id]);
if (!entity) {
// Same error whether entity doesn't exist or belongs to another tenant
throw new NotFoundError('Entity not found');
}
return entity;
}
async function updateEntity(authCtx: AuthContext, entityId: string, updates: Partial<Entity>): Promise<Entity> {
// Validate ownership first
const entity = await getEntity(authCtx, entityId);
// Update with tenant_id in WHERE clause (belt and suspenders)
await db.exec(`
UPDATE entities
SET data = ?, updated_at = ?
WHERE id = ? AND tenant_id = ?
`, [
JSON.stringify({ ...entity, ...updates }),
Date.now(),
entityId,
authCtx.tenant_id
]);
return { ...entity, ...updates };
}

Key: Both read and write operations validate tenant_id in WHERE clause.


✅ Pattern 3: Fact Append with tenant_id Injection

Section titled “✅ Pattern 3: Fact Append with tenant_id Injection”
// CORRECT: Force tenant_id on append, validate all references
async function appendFact(authCtx: AuthContext, fact: Partial<Fact>): Promise<Fact> {
// Force tenant_id from auth (ignore request value)
const validatedFact: Fact = {
...fact,
id: fact.id ?? generateFactId(),
tenant_id: authCtx.tenant_id, // Always from auth
timestamp: fact.timestamp ?? Date.now()
};
// Validate entity belongs to tenant (if specified)
if (validatedFact.entity_id) {
await validateEntityOwnership(authCtx, validatedFact.entity_id);
}
// Validate campaign belongs to tenant
if (validatedFact.campaign_id) {
await validateEntityOwnership(authCtx, validatedFact.campaign_id);
}
// Validate asset belongs to tenant
if (validatedFact.asset_id) {
await validateEntityOwnership(authCtx, validatedFact.asset_id);
}
// Validate contact belongs to tenant
if (validatedFact.contact_id) {
await validateEntityOwnership(authCtx, validatedFact.contact_id);
}
// Append to DO
const accountDO = getAccountDO(authCtx.tenant_id, validatedFact.entity_id);
return accountDO.appendFact(validatedFact);
}
async function validateEntityOwnership(authCtx: AuthContext, entityId: string): Promise<void> {
const entity = await db.queryOne(`
SELECT id FROM entities WHERE id = ? AND tenant_id = ?
`, [entityId, authCtx.tenant_id]);
if (!entity) {
throw new ForbiddenError(`Entity ${entityId} does not belong to tenant`);
}
}

Key: All entity references in Fact are validated before append. Fact.tenant_id is always forced from auth.


✅ Pattern 4: Cross-Tenant References for Global Entities

Section titled “✅ Pattern 4: Cross-Tenant References for Global Entities”
// CORRECT: Global entities (tools, vendors, users) have tenant_id = null
async function getTool(toolId: string): Promise<Tool> {
// Tools are global, no tenant_id scoping
const tool = await db.queryOne(`
SELECT * FROM entities
WHERE id = ? AND type = 'tool' AND tenant_id IS NULL
`, [toolId]);
if (!tool) {
throw new NotFoundError('Tool not found');
}
return tool;
}
// Global entities are READ-ONLY for tenants
async function updateTool(authCtx: AuthContext, toolId: string, updates: Partial<Tool>): Promise<Tool> {
// Only platform account can modify global entities
if (authCtx.tenant_id !== 'platform') {
throw new ForbiddenError('Cannot modify global entities');
}
await db.exec(`
UPDATE entities
SET data = ?, updated_at = ?
WHERE id = ? AND type = 'tool' AND tenant_id IS NULL
`, [
JSON.stringify(updates),
Date.now(),
toolId
]);
return getTool(toolId);
}

Key: Global entities have tenant_id = null explicitly. Only platform account can modify.


// CORRECT: DO IDs include tenant_id, validated on access
function getAccountDO(authCtx: AuthContext, accountId: string): DurableObjectStub {
// Validate accountId belongs to tenant
// Account IDs must be prefixed with tenant_id
if (!accountId.startsWith(`acct_${authCtx.tenant_id}_`)) {
throw new ForbiddenError('Account does not belong to tenant');
}
// DO ID: {tenant_id}_{entity_id}
const doId = env.ACCOUNT_LEDGER.idFromName(`${authCtx.tenant_id}_${accountId}`);
return env.ACCOUNT_LEDGER.get(doId);
}
// Within DO: Validate tenant_id on every operation
class AccountDO extends DurableObject {
private tenantId: string;
constructor(state: DurableObjectState, env: Env) {
super(state, env);
// Extract tenant_id from DO name
const doName = state.id.name;
this.tenantId = doName.split('_')[0]; // "tenant_acme_account_123" → "tenant_acme"
}
async fetch(request: Request): Promise<Response> {
// Extract auth context
const authCtx = await authenticateRequest(request);
// Validate request is for this tenant
if (authCtx.tenant_id !== this.tenantId) {
throw new ForbiddenError('Tenant mismatch');
}
// Process request (tenant_id already validated)
return this.handleRequest(request);
}
}

Key: DO IDs encode tenant_id. DO validates auth matches its tenant on every request.


class AccountDO extends DurableObject {
private tenantId: string;
private entityId: string;
private sql: SqlStorage;
constructor(state: DurableObjectState, env: Env) {
super(state, env);
this.sql = state.storage.sql;
// Parse DO name: "{tenant_id}_{entity_id}"
const [tenantId, ...entityIdParts] = state.id.name.split('_');
this.tenantId = tenantId;
this.entityId = entityIdParts.join('_');
}
async fetch(request: Request): Promise<Response> {
const authCtx = await authenticateRequest(request);
// Validate tenant_id matches DO
if (authCtx.tenant_id !== this.tenantId) {
throw new ForbiddenError('Tenant mismatch');
}
// All queries automatically scoped to this.tenantId
return this.handleRequest(request);
}
async queryFacts(filters: FactFilters): Promise<Fact[]> {
// tenant_id implicit from DO context
return this.sql.exec(`
SELECT * FROM facts
WHERE tenant_id = ?
AND type = ?
AND timestamp >= ?
`, [
this.tenantId, // From DO construction
filters.type,
filters.since
]).all();
}
}

Key: DO extracts tenant_id from its name on construction. All operations use this.tenantId.


Cross-DO Coordination with tenant_id Validation

Section titled “Cross-DO Coordination with tenant_id Validation”
// AssetDO checking budget in AccountDO
async checkCampaignBudget(campaignId: string): Promise<boolean> {
// 1. Resolve campaign entity (validates ownership)
const campaign = await this.resolveCampaign(campaignId);
// 2. Validate campaign belongs to this tenant
if (campaign.tenant_id !== this.tenantId) {
throw new ForbiddenError('Campaign does not belong to this tenant');
}
// 3. Get AccountDO for campaign's parent account
const accountId = campaign.parent_id;
const accountDO = env.ACCOUNT_LEDGER.get(
env.ACCOUNT_LEDGER.idFromName(`${this.tenantId}_${accountId}`)
);
// 4. Stub call with tenant_id in request
const response = await accountDO.fetch(new Request('https://fake/budget/check', {
method: 'POST',
headers: {
'X-Tenant-ID': this.tenantId, // For validation
'X-Campaign-ID': campaignId
},
body: JSON.stringify({ campaign_id: campaignId })
}));
const { available } = await response.json();
return available;
}
// In AccountDO
async handleBudgetCheck(request: Request): Promise<Response> {
const requestTenantId = request.headers.get('X-Tenant-ID');
// Validate tenant_id matches DO
if (requestTenantId !== this.tenantId) {
throw new ForbiddenError('Tenant mismatch in cross-DO call');
}
const { campaign_id } = await request.json();
const available = await this.checkBudget(campaign_id);
return Response.json({ available });
}

Key: Cross-DO calls include tenant_id in request. Receiving DO validates it matches.


Dispatch Worker: Extract tenant_id from Auth

Section titled “Dispatch Worker: Extract tenant_id from Auth”
// Dispatch Worker
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// 1. Authenticate request
const authCtx = await authenticateRequest(request);
// 2. Get tenant worker from WfP namespace
// Script name = tenant_id
const tenantWorker = env.TENANT_WORKERS.get(authCtx.tenant_id);
if (!tenantWorker) {
return new Response('Tenant not found', { status: 404 });
}
// 3. Forward request with tenant_id header
const tenantRequest = new Request(request.url, {
method: request.method,
headers: new Headers(request.headers),
body: request.body
});
// Add auth context headers (tenant worker trusts dispatch worker)
tenantRequest.headers.set('x-z0-tenant-id', authCtx.tenant_id);
tenantRequest.headers.set('x-z0-key-id', authCtx.key_id);
tenantRequest.headers.set('x-z0-scopes', authCtx.scopes.join(','));
// 4. Dispatch to tenant worker
return tenantWorker.fetch(tenantRequest);
}
};

Key: Dispatch worker extracts tenant_id from auth, adds it to headers for tenant worker.


Tenant Worker: Trust Dispatch, Validate tenant_id

Section titled “Tenant Worker: Trust Dispatch, Validate tenant_id”
// Tenant Worker (user-controlled code)
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Extract tenant_id from trusted dispatch worker header
const tenantId = request.headers.get('x-z0-tenant-id');
const keyId = request.headers.get('x-z0-key-id');
const scopes = request.headers.get('x-z0-scopes')?.split(',') || [];
if (!tenantId) {
return new Response('Unauthorized', { status: 401 });
}
// Validate this worker is for the correct tenant
// (env.TENANT_ID set at deployment time)
if (tenantId !== env.TENANT_ID) {
return new Response('Tenant mismatch', { status: 403 });
}
// Build auth context
const authCtx: AuthContext = {
tenant_id: tenantId,
key_id: keyId,
scopes
};
// All operations automatically scoped to authCtx.tenant_id
return handleRequest(request, authCtx);
}
};

Key: Tenant worker validates tenant_id matches its deployment-time tenant binding.


// CORRECT: All queries include tenant_id
async function queryFacts(authCtx: AuthContext, filters: FactFilters): Promise<Fact[]> {
const stmt = env.D1.prepare(`
SELECT * FROM facts
WHERE tenant_id = ?
AND type = ?
AND timestamp >= ?
AND timestamp < ?
ORDER BY timestamp DESC
LIMIT ?
`);
const { results } = await stmt.bind(
authCtx.tenant_id,
filters.type,
filters.start_date,
filters.end_date,
filters.limit || 100
).all();
return results;
}
// CORRECT: Aggregations scoped to tenant
async function getChargesSummary(authCtx: AuthContext, startDate: Date, endDate: Date): Promise<Summary> {
const stmt = env.D1.prepare(`
SELECT
campaign_id,
COUNT(*) as count,
SUM(amount) as total
FROM facts
WHERE tenant_id = ?
AND type = 'charge'
AND timestamp >= ?
AND timestamp < ?
GROUP BY campaign_id
`);
const { results } = await stmt.bind(
authCtx.tenant_id,
startDate.getTime(),
endDate.getTime()
).all();
return results;
}

Key: Every query has WHERE tenant_id = ? with value from auth context.


// CORRECT: KV keys prefixed with tenant_id
async function cacheValue(authCtx: AuthContext, key: string, value: any, ttl: number): Promise<void> {
const scopedKey = `${authCtx.tenant_id}:${key}`;
await env.KV.put(scopedKey, JSON.stringify(value), { expirationTtl: ttl });
}
async function getCachedValue(authCtx: AuthContext, key: string): Promise<any> {
const scopedKey = `${authCtx.tenant_id}:${key}`;
const cached = await env.KV.get(scopedKey);
return cached ? JSON.parse(cached) : null;
}
// WRONG: KV keys without tenant_id prefix
async function cacheValueWrong(key: string, value: any): Promise<void> {
await env.KV.put(key, JSON.stringify(value)); // Leaks across tenants
}

Key: KV keys always prefixed with ${tenant_id}: to prevent cross-tenant access.


describe('tenant_id enforcement', () => {
it('should reject queries without tenant_id from auth', async () => {
const authCtx = { tenant_id: 'tenant_a', scopes: ['read:facts'] };
// Attempt to query tenant_b's data
const request = {
tenant_id: 'tenant_b', // Request specifies different tenant
type: 'charge'
};
await expect(queryFacts(authCtx, request)).rejects.toThrow(ForbiddenError);
});
it('should force tenant_id on Fact append', async () => {
const authCtx = { tenant_id: 'tenant_a', scopes: ['write:facts'] };
const fact = {
type: 'charge',
tenant_id: 'tenant_b', // Request tries to set wrong tenant
amount: 100
};
const appended = await appendFact(authCtx, fact);
// tenant_id forced to tenant_a
expect(appended.tenant_id).toBe('tenant_a');
});
it('should validate entity ownership before Fact append', async () => {
const authCtx = { tenant_id: 'tenant_a', scopes: ['write:facts'] };
// Create entity for tenant_b
await createEntity({ id: 'entity_b', tenant_id: 'tenant_b' });
// Try to append Fact referencing tenant_b's entity
const fact = {
type: 'outcome',
entity_id: 'entity_b', // Belongs to tenant_b
tenant_id: 'tenant_a'
};
await expect(appendFact(authCtx, fact)).rejects.toThrow(ForbiddenError);
});
});

describe('cross-tenant isolation', () => {
it('should prevent tenant_a from reading tenant_b data', async () => {
// Setup: Create Facts for both tenants
await appendFact({ tenant_id: 'tenant_a' }, { type: 'charge', amount: 100 });
await appendFact({ tenant_id: 'tenant_b' }, { type: 'charge', amount: 200 });
// Test: tenant_a queries Facts
const authCtxA = { tenant_id: 'tenant_a', scopes: ['read:facts'] };
const factsA = await queryFacts(authCtxA, { type: 'charge' });
// Should only see tenant_a's Facts
expect(factsA).toHaveLength(1);
expect(factsA[0].amount).toBe(100);
expect(factsA[0].tenant_id).toBe('tenant_a');
});
it('should prevent DO name manipulation', async () => {
const authCtxA = { tenant_id: 'tenant_a', scopes: ['read:facts'] };
// Try to access tenant_b's DO by constructing DO name
const attemptedAccess = () => {
const doId = env.ACCOUNT_LEDGER.idFromName('tenant_b_account_123');
return env.ACCOUNT_LEDGER.get(doId).fetch(new Request('https://fake/facts'));
};
// Should fail because auth context doesn't match DO tenant
await expect(attemptedAccess()).rejects.toThrow(ForbiddenError);
});
});

describe('tenant_id penetration tests', () => {
it('should prevent SQL injection via tenant_id', async () => {
const maliciousAuthCtx = {
tenant_id: "tenant_a' OR '1'='1", // SQL injection attempt
scopes: ['read:facts']
};
// Prepared statements prevent SQL injection
const facts = await queryFacts(maliciousAuthCtx, { type: 'charge' });
// Should return empty (no tenant matches that ID)
expect(facts).toHaveLength(0);
});
it('should prevent tenant_id tampering in API requests', async () => {
const authCtx = { tenant_id: 'tenant_a', scopes: ['read:facts'] };
// Malicious request with different tenant_id in body
const request = {
tenant_id: 'tenant_b',
type: 'charge'
};
const facts = await queryFacts(authCtx, request);
// Should only see tenant_a's data (request.tenant_id ignored)
expect(facts.every(f => f.tenant_id === 'tenant_a')).toBe(true);
});
it('should prevent cross-tenant entity references', async () => {
const authCtx = { tenant_id: 'tenant_a', scopes: ['write:facts'] };
// Create entity for tenant_b
await createEntity({ id: 'campaign_b', tenant_id: 'tenant_b', type: 'campaign' });
// Try to create Fact referencing tenant_b's campaign
const fact = {
type: 'invocation',
campaign_id: 'campaign_b', // Belongs to tenant_b
tenant_id: 'tenant_a'
};
await expect(appendFact(authCtx, fact)).rejects.toThrow(ForbiddenError);
});
});

Before deploying any code that touches tenant data:

  • All SELECT queries include WHERE tenant_id = ?
  • tenant_id value always from authCtx.tenant_id, never from request
  • Prepared statements used (no string concatenation)
  • Aggregations include tenant_id in WHERE clause
  • All INSERT operations set tenant_id = authCtx.tenant_id
  • All UPDATE operations include WHERE tenant_id = ?
  • All DELETE operations include WHERE tenant_id = ?
  • Fact appends force tenant_id from auth context
  • All entity_id references validated against authCtx.tenant_id
  • campaign_id validated (belongs to tenant)
  • asset_id validated (belongs to tenant)
  • contact_id validated (belongs to tenant)
  • deal_id validated (belongs to tenant)
  • DO IDs include tenant_id prefix
  • DO validates auth.tenant_id matches DO.tenantId on every request
  • Cross-DO calls include tenant_id validation
  • DO SQLite queries include WHERE tenant_id = ?
  • Dispatch worker extracts tenant_id from auth, not request
  • Tenant worker validates tenant_id matches deployment binding
  • Tenant-specific bindings (KV, D1, secrets) configured per tenant
  • No global bindings shared across tenants
  • KV keys prefixed with ${tenant_id}:
  • Cache keys prefixed with ${tenant_id}:
  • No global cache shared across tenants
  • Errors do not leak tenant_id or entity existence
  • Same error for “not found” vs “belongs to other tenant”
  • No enumeration possible via error messages

PrincipleImplementation
tenant_id sourceAlways from auth context, never from request
Query enforcementEvery query includes WHERE tenant_id = ?
Write enforcementtenant_id forced on INSERT/UPDATE, validated on reference
DO isolationDO ID encodes tenant_id, validated on every operation
WfP isolationPer-tenant scripts with isolated bindings
Global entitiestenant_id = null, read-only for tenants
ValidationAll entity references validated against auth.tenant_id
TestingUnit, integration, and penetration tests enforce isolation

Critical Path: API Key → Auth Context → tenant_id → Query WHERE clause

Any break in this chain compromises multi-tenant isolation. tenant_id enforcement is the foundation of z0’s security model.