The headlines about multi-tenant SaaS aren’t “lacked features.” They’re “Customer A saw Customer B’s data.” This chapter is the floor.
| Threat | Attack path | Mitigating layer |
|---|---|---|
| T1 Forged header | Swap X-Tenant-ID |
Layer 1 |
| T2 SQL injection | Bypass WHERE clause | Layer 2 + 3 |
| T3 Internal mistake | Admin runs cross-tenant query | Layer 2 |
| T4 App bug | New endpoint skips tenant filter | Layer 2 + 3 |
All four have real incidents in 2024–2025. No single layer suffices.
export async function tenantMiddleware(req, res, next) {
const apiKey = req.headers['x-rag-api-key'];
const claimed = req.headers['x-tenant-id'];
if (!apiKey || !claimed) return res.status(401).json({error:'missing credentials'});
const owner = await db.query(
'SELECT tenant_id FROM api_keys WHERE key_hash=$1 AND revoked_at IS NULL',
[hash(apiKey)],
);
if (!owner.rows[0] || owner.rows[0].tenant_id !== claimed) {
return res.status(403).json({error:'tenant mismatch'});
}
req.tenant_id = claimed;
next();
}
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
CREATE POLICY documents_tenant ON documents
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
App side:
async function withTenantConnection<T>(tid: string, fn: (c: PgClient) => Promise<T>) {
const c = await pool.connect();
try {
await c.query(`SET LOCAL app.current_tenant_id = '${tid}'`);
return await fn(c);
} finally { c.release(); }
}
SET LOCAL, not SETBYPASSRLS role attribute: CI check:SELECT rolname FROM pg_roles
WHERE rolbypassrls = true AND rolcanlogin = true;
Even with RLS we require explicit WHERE tenant_id = $x:
// ❌ relies on RLS only
const docs = await client.query('SELECT * FROM documents');
// ✅ double insurance
const docs = await client.query(
'SELECT * FROM documents WHERE tenant_id = $1', [tenantId]
);
Reasons: RLS could be accidentally disabled, code review is easier, and optimizer plan is more aggressive.
Enforced via ORM wrapper:
class TenantScopedDb {
constructor(private client: PgClient, private tenantId: string) {}
documents() { return this.client.selectFrom('documents').where('tenant_id','=',this.tenantId); }
}
Linter bans raw client.query() calls.
async function acquireClient(tenantId: string) {
const c = await pool.connect();
await c.query('RESET app.current_tenant_id');
await c.query(`SET LOCAL app.current_tenant_id = '${tenantId}'`);
await c.query('SET LOCAL search_path = public');
return c;
}
Monitoring: if any connection’s current_setting() disagrees with request header → page.
WHERE tenant_id = pg_current_tenant() automaticallyCI runs per PR:
-- all tenant tables must have RLS
SELECT schemaname, tablename, rowsecurity FROM pg_tables
WHERE schemaname='public' AND tablename IN ('documents','chunks','embeddings','wiki_pages','queries');
it('prevents cross-tenant read', async () => {
const tA = await createTenant();
const tB = await createTenant();
const doc = await createDocument(tA.id, 'secret A');
const r = await withTenant(tB.id, db =>
db.selectFrom('documents').where('id','=',doc.id).executeTakeFirst());
expect(r).toBeUndefined();
});
Quarterly red-team review: SQLi variants, header forgery, JWT tampering, race conditions.
BYPASSRLSSET LOCAL + reset every acquireNavigation: ← Ch 5 · 📖 Contents · Ch 7 →