Postgres RLS for Multi-Tenant AI: Tenant Isolation Done Right (2026)
By Sagar Shankaran, Founder of CallSphere
Move tenant filtering out of your app and into Postgres. Row Level Security gives you database-enforced isolation that survives ORM bugs, hand-rolled SQL, and AI agents writing queries on the fly.
Key takeaways
TL;DR — When AI agents start writing SQL on behalf of users, app-layer tenant filtering becomes a liability. RLS pushes the
tenant_idcheck into the database where no agent can route around it. Set up correctly, it adds <5% query overhead.
What you'll build
A multi-tenant schema where every AI agent query — direct, ORM-generated, or LLM-emitted — is automatically scoped to the calling tenant via session-level GUC variables and RLS policies.
Schema
CREATE TABLE conversations (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL,
agent_id UUID NOT NULL,
transcript TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE conversations FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON conversations
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
CREATE INDEX conversations_tenant_idx ON conversations (tenant_id, created_at DESC);
Architecture
flowchart TD
REQ[HTTP request] --> AUTH[Auth middleware]
AUTH --> SET[SET app.tenant_id GUC]
SET --> APP[App + Prisma]
APP --> AGENT[AI agent SQL]
AGENT --> PG[(Postgres + RLS)]
PG -->|Policy enforced| ROWS[Only tenant rows]
Step 1 — Set the tenant per request
// Express / Next middleware
import { prisma } from "@/lib/db";
export async function withTenant<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
return prisma.$transaction(async (tx) => {
await tx.$executeRawUnsafe(
`SET LOCAL app.tenant_id = '${tenantId}'`,
);
return fn();
});
}
SET LOCAL is critical — it scopes the GUC to the current transaction so pooled connections don't leak.
Step 2 — Use a non-superuser app role
CREATE ROLE app_user NOINHERIT LOGIN PASSWORD '...';
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
Superusers bypass RLS — never connect your app as one.
Hear it before you finish reading
Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.
Step 3 — FORCE RLS on table owners
By default, table owners bypass RLS. FORCE ROW LEVEL SECURITY makes the policy apply to everyone except true superusers.
Step 4 — Test with two tenants
SET app.tenant_id = '11111111-1111-1111-1111-111111111111';
SELECT count(*) FROM conversations; -- only tenant A
SET app.tenant_id = '22222222-2222-2222-2222-222222222222';
SELECT count(*) FROM conversations; -- only tenant B
RESET app.tenant_id;
SELECT count(*) FROM conversations; -- 0 rows (or error if NOT NULL cast)
Step 5 — Handle WITH CHECK for INSERTs
The WITH CHECK half ensures inserts also satisfy the policy. Without it, an attacker could write rows for a different tenant.
INSERT INTO conversations (tenant_id, agent_id, transcript)
VALUES ('22222222-...'::uuid, ...);
-- ERROR: new row violates row-level security policy
Step 6 — Index policies for performance
RLS is a transparent AND tenant_id = $1 appended to every query. Make sure every queried table has an index that leads with tenant_id.
CREATE INDEX conversations_tenant_created_idx
ON conversations (tenant_id, created_at DESC);
Pitfalls
- Forgetting FORCE ROW LEVEL SECURITY — table owner bypasses by default.
- Using SET (not SET LOCAL) in a pooled connection — the GUC leaks to the next request.
- Connecting as superuser — RLS doesn't apply. Always use a constrained role.
- Casting in policy —
tenant_id = current_setting(...)::uuidis fine, but mismatched types silently disable index usage. - Subqueries that leak — security-definer functions can bypass RLS; audit them carefully.
CallSphere production note
OneRoof — CallSphere's property-management vertical — relies on RLS across 115+ DB tables to keep landlords from ever seeing each other's tenants, leases, or conversation history. Healthcare and Behavioral Health get the same treatment plus a separate healthcare_voice Prisma schema for HIPAA defense-in-depth; UrackIT layers RLS over Supabase + ChromaDB. 37 agents · 90+ tools · 6 verticals. Plans: $149/$499/$1,499 — 14-day trial, 22% affiliate.
Still reading? Stop comparing — try CallSphere live.
CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.
FAQ
Q: RLS or schema-per-tenant? RLS for >50 tenants. Schema-per-tenant scales tooling badly past that.
Q: Performance overhead?
2-5% on indexed queries when policy uses current_setting + indexed column. Measure with EXPLAIN.
Q: Can RLS replace permissions in my app? RLS handles row visibility. App-layer authz still owns RBAC, audit logging, and rate limits.
Q: Does Prisma support RLS? Yes — set the GUC in a transaction, then call your Prisma queries inside it.
Q: How do I let admins see all tenants?
Use a separate role with BYPASSRLS, or include OR is_admin() in the policy.
Sources
Written by
Sagar Shankaran· Founder, CallSphere
Sagar Shankaran is the founder of CallSphere, where he builds production AI voice and chat agents deployed across healthcare, hospitality, real estate, and home services. He writes about agentic AI, LLM engineering, and shipping voice agents that handle real calls in production.
Try CallSphere AI Voice Agents
See how AI voice agents work for your industry. Live demo available -- no signup required.