When engineering a modern SaaS platform, guaranteeing customer data isolation is your single highest security mandate.
In early-stage development, engineering teams frequently rely on application-level filtering:
// ⚠️ HIGH RISK: If a developer forgets \`where: { tenantId }\`, customer data leaks!
export async function getLeads(req: Request) {
const session = await getSession(req);
// Relies entirely on developer discipline in every single route handler:
return await db.select().from(leadsTable).where(eq(leadsTable.tenantId, session.tenantId));
}
This pattern is a ticking time bomb. All it takes is one junior engineer forgetting a where clause in an aggregation query, a background worker pulling from a queue, or an export script, and you have exposed proprietary customer records to competing tenants.
In ⚡ PLYXO (CRO • SEO • AIO • AEO • GEO), we enforce database-level multi-tenancy using PostgreSQL Row-Level Security (RLS) as a non-bypassable backstop.
1. How PostgreSQL Row-Level Security Works Under the Hood
Postgres RLS operates inside the database engine itself. When a query is planned and executed, PostgreSQL transparently appends security qualifiers to every table scan. Even if an attacker executes raw SQL injection like SELECT * FROM audits;, the database engine physically refuses to return rows that do not match the current session's tenant qualifier.
┌────────────────────────────────────────────────────────────────────────┐
│ POSTGRES ROW-LEVEL SECURITY ENFORCEMENT │
└────────────────────────────────────────────────────────────────────────┘
│
Client Request: "SELECT * FROM leads;"
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Transaction Init: SET LOCAL app.current_tenant_id = 'tenant_123' │
└──────────────────────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ PostgreSQL RLS Engine Rewrite │
│ Query rewritten to: │
│ SELECT * FROM leads WHERE tenant_id = 'tenant_123'::uuid │
└──────────────────────────────┬───────────────────────────────┘
│
┌──────────────┴──────────────┐
▼ ▼
[Tenant 123 Data] [Tenant 456 Data]
(RETURNED) (DISCARDED)
2. Production SQL Schema: Enforcing Policies
Here is the exact SQL definition we use to secure tenant tables:
-- Step 1: Enable RLS on core tables
ALTER TABLE tenants ENABLE ROW LEVEL SECURITY;
ALTER TABLE leads ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_reports ENABLE ROW LEVEL SECURITY;
ALTER TABLE visual_inspections ENABLE ROW LEVEL SECURITY;
-- Step 2: Create a tenant isolation policy
-- The policy checks the session variable 'app.current_tenant_id'
CREATE POLICY tenant_isolation_policy ON audit_reports
FOR ALL
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
-- Step 3: Enforce strict table constraints
ALTER TABLE audit_reports FORCE ROW LEVEL SECURITY;
Notice FORCE ROW LEVEL SECURITY. By default, table owners bypass RLS unless FORCE is enabled. This ensures even database migration users cannot accidentally mutate cross-tenant state.
3. Integrating RLS with TypeScript & Drizzle ORM
To pass the authenticated tenant ID from your Next.js session into Postgres without leaking connection pool state, we wrap database operations in an atomic transaction:
import { sql } from 'drizzle-orm';
import { db } from '@/db';
/**
* Scopes a database operation to a specific tenant ID via Postgres RLS
*/
export async function withTenantContext<T>(
tenantId: string,
operation: (tx: typeof db) => Promise<T>
): Promise<T> {
return await db.transaction(async (tx) => {
// Inject session variable into this specific transaction boundary
await tx.execute(sql`SET LOCAL app.current_tenant_id = ${tenantId};`);
// Execute queries - RLS is now active and enforced by PostgreSQL
return await operation(tx);
});
}
Now, your service layer looks clean and completely immune to data leakage:
export async function getTenantAuditReports(tenantId: string) {
return await withTenantContext(tenantId, async (tx) => {
// No manual WHERE clause required for security!
// The database guarantees only this tenant's reports are accessible.
return await tx.select().from(auditReportsTable);
});
}
4. Performance Benchmarks: RLS vs Manual Filtering
A frequent concern among architects is query overhead. Does RLS degrade database throughput?
We ran pgbench benchmarks across 1,000,000 synthetic rows with compound B-tree indexes on (tenant_id, created_at):
| Metric | Manual WHERE Query | PostgreSQL RLS | Delta |
|---|---|---|---|
| P50 Latency | 2.14 ms | 2.18 ms | +0.04 ms |
| P95 Latency | 4.82 ms | 4.91 ms | +0.09 ms |
| P99 Latency | 11.20 ms | 11.35 ms | +0.15 ms |
| Throughput (QPS) | 14,200 req/s | 13,950 req/s | -1.7% |
The performance delta is less than 2%, while completely eliminating the risk of catastrophic data breaches.
5. Explore the Open-Source Architecture
Plyxo's multi-tenant core, schemas, and RLS migrations are 100% open-source:
👉 Inspect our PostgreSQL migrations on GitHub: pixelfogg/Plyxo-CRO-SEO-AIO-AEO-GEO
Top comments (0)