PERN• 5 MIN READ
Kirito

Kirito

6/10/20265 min read

The Day PostgreSQL Row-Level Security Broke My API

Adding Row-Level Security to PostgreSQL seemed smart — until every API request started returning empty arrays and users thought their data was deleted.

The Day PostgreSQL Row-Level Security Broke My API

I love PostgreSQL. It's powerful, reliable, and has features that MongoDB users can only dream about.

But one feature almost cost me a client.

Row-Level Security (RLS).

The "Smart" Decision

I was building a multi-tenant SaaS with the PERN stack:

  • PostgreSQL (with Prisma ORM)
  • Express.js
  • React
  • Node.js

To keep tenant data separate, I decided to use PostgreSQL RLS instead of filtering in application code.

"More secure," I thought. "The database will handle everything automatically."

I set up RLS like this:

-- Enable RLS on all tables ALTER TABLE projects ENABLE ROW LEVEL SECURITY; ALTER TABLE tasks ENABLE ROW LEVEL SECURITY; ALTER TABLE users ENABLE ROW LEVEL SECURITY; -- Create policy for projects CREATE POLICY tenant_isolation ON projects USING (tenant_id = current_setting('app.current_tenant_id')::uuid); -- Same for tasks and users...

And in my Prisma middleware, I set the tenant context:

prisma.$use(async (params, next) => { const tenantId = getCurrentTenantId(); // From JWT await prisma.$executeRaw`SET app.current_tenant_id = ${tenantId}`; return next(params); });

Beautiful. Elegant. Wrong.

The Disaster

One morning, users started reporting:

"My projects are gone!" "I had 50 tasks yesterday, now I see nothing!" "Did someone delete my account?"

I checked the database directly:

SELECT COUNT(*) FROM projects WHERE tenant_id = 'user-tenant-id'; -- Returns: 47 projects

Then I checked the API:

curl https://api.myapp.com/projects # Response: []

Empty array.

The data was there. The API was working. What happened?

Debugging the Nightmare

I spent 4 hours checking:

  • API routes
  • Authentication middleware
  • Prisma queries
  • Database connections

Nothing.

Finally, I connected directly to PostgreSQL and ran the same query the API was using:

SELECT * FROM projects; -- Returns: 0 rows

Wait, what?

I ran it again with a different user:

SET app.current_tenant_id = 'some-tenant-id'; SELECT * FROM projects; -- Returns: projects from THAT tenant only

Then it hit me.

The tenant ID wasn't being set correctly in the API.

But I had middleware that set it on every request. How could it fail?

The Real Bug

I found the issue in my authentication flow:

// My middleware (WRONG) app.use(async (req, res, next) => { const token = req.headers.authorization?.split(' ')[1]; if (token) { const decoded = jwt.verify(token, SECRET); req.tenantId = decoded.tenantId; // Set for Prisma await prisma.$executeRaw`SET app.current_tenant_id = ${req.tenantId}`; } next(); });

The problem? Prisma connection pooling.

Prisma reuses database connections across multiple requests. When I set app.current_tenant_id on a connection, it persisted for the NEXT request too — but only if that request reused the same connection.

So:

  • Request 1 (Tenant A) → Sets tenant_id = A → Works fine
  • Request 2 (Tenant B) → Reuses same connection → tenant_id is STILL A → Sees NO data

Some users saw nothing. Others saw OTHER tenants' data.

This was a security breach.

The Fix

I learned that PostgreSQL session variables are connection-scoped. With connection pooling, you MUST reset them every time.

The correct approach:

// Use a connection pool that resets state const resetAndSetTenant = async (tenantId) => { // Reset any existing setting await prisma.$executeRaw`RESET app.current_tenant_id`; // Set new value await prisma.$executeRaw`SET app.current_tenant_id = ${tenantId}`; }; // Then in middleware app.use(async (req, res, next) => { const tenantId = extractTenantFromToken(req); await resetAndSetTenant(tenantId); next(); });

But even better? Don't use RLS with Prisma's default connection pool.

I switched to a different pattern:

// Filter in application code instead const getProjects = async (tenantId) => { return prisma.project.findMany({ where: { tenantId: tenantId } }); };

It's not as "elegant," but it's predictable. No connection state issues. No cross-tenant data leaks.

What I Learned

1. RLS + Connection Pooling = Danger

PostgreSQL session variables don't automatically reset. If you use RLS, you need connection-level isolation or explicit reset on every request.

2. Test with Multiple Concurrent Users

My tests only used one user at a time. The bug only appeared under real load.

3. Security Features Can Create Security Holes

RLS is supposed to protect data. Improper implementation can expose it.

The Audit

After fixing, I ran a security audit:

-- Check if any user accessed wrong tenant data SELECT p.tenant_id as actual_tenant, u.email, a.action, a.timestamp FROM audit_log a JOIN projects p ON a.project_id = p.id JOIN users u ON a.user_id = u.id WHERE p.tenant_id != u.tenant_id;

Thankfully, the bug only lasted 2 hours and only affected "view" operations. No data was modified across tenants.

But I had nightmares for a week.

Better RLS Pattern (If You Must Use It)

// Create a dedicated pool for each tenant const pools = new Map(); const getTenantPool = (tenantId) => { if (!pools.has(tenantId)) { pools.set(tenantId, new PrismaClient({ datasources: { db: { url: getConnectionStringWithTenant(tenantId) } } })); } return pools.get(tenantId); }; // Or use pgBouncer transaction pooling (not session pooling)

Commands That Help

Check current RLS policies

SELECT schemaname, tablename, policyname, permissive, roles, cmd, qual FROM pg_policies;

Temporarily disable RLS (emergency only!)

ALTER TABLE projects DISABLE ROW LEVEL SECURITY; -- Do what you need ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

View current session variables

SELECT name, setting FROM pg_settings WHERE category LIKE 'Customized%';

Conclusion

PostgreSQL is incredible. RLS is powerful.

But with great power comes great responsibility — and great foot-guns.

Now I have a rule: Never use session variables with connection pooling unless you fully understand the isolation model.

And I always test with 10 concurrent users before deploying auth-related features.

That "elegant" RLS solution cost me 8 hours of debugging and one very angry client email.

Worth it? Never again.