Your RLS Is Enabled. That Doesn't Mean It's Working.

4 min read

How a single misconfigured policy almost exposed hundreds of private user profiles on launch day.

The setup

A social app was two weeks from public beta. User authentication, a social graph, photo uploads, a full PostgreSQL backend. The team had done their own security pass and felt confident. Row Level Security was enabled on every table. Checkboxes ticked.

Then I looked closer.

What I found

Their RLS policies were enabled — but their social graph queries used a join condition that Postgres was silently bypassing under specific access patterns. The result: any authenticated user could pull profile data from accounts set to private.

One misconfigured policy. Every private user profile exposed to any logged-in user on day one of launch.

This is what the vulnerable query pattern looked like:

-- Policy looked correct on the surface
CREATE POLICY "Users can view public profiles"
ON profiles FOR SELECT
USING (is_public = true);

-- But social graph query bypassed it
SELECT p.*
FROM profiles p
JOIN follows f ON f.following_id = p.id
WHERE f.follower_id = auth.uid();
-- RLS not enforced on joined table in this context

The join was fetching profile rows directly in a context where the RLS policy wasn't being evaluated the way the team assumed. Postgres didn't throw an error. The app didn't break. It just quietly returned data it shouldn't have.

Why this happens

For non-technical founders: Enabling RLS is like putting a lock on your door. But if the lock is fitted incorrectly, the door still closes and looks locked — it just opens with the wrong key. You'd never know until someone tried.

For developers: RLS policies are evaluated per-table, not per-query. When you join tables, Postgres evaluates the policy on the table being scanned — but certain query patterns, especially with security definer functions or direct joins, can cause policies to be skipped entirely. It's not a bug, it's how the permission model works. But it catches teams who haven't stress-tested their policies against real query patterns.

What the fix looked like

-- Rewritten policy with explicit auth check
CREATE POLICY "Users can view allowed profiles"
ON profiles FOR SELECT
USING (
is_public = true
OR id = auth.uid()
OR EXISTS (
SELECT 1 FROM follows
WHERE follower_id = auth.uid()
AND following_id = profiles.id
)
);
-- Now enforced correctly across all query patterns

The real problem

The team wasn't careless. They were thorough by their own standard. The issue is that when you review your own security, you test for what you know to look for. You don't catch what you don't.

This is true for every team, at every level. Security review is not about intelligence — it's about perspective. A second set of eyes, especially one that has seen these failure patterns before, catches what familiarity blinds you to.

If you're heading into beta

Ask yourself these three questions:

  • Have you tested your RLS policies against every query pattern in your app, not just simple selects?
  • Have your Edge Functions been reviewed for JWT validation, or are they trusting Supabase auth implicitly?
  • Are your storage bucket policies scoped to the user who owns the file?

If any of those give you pause, you're not ready to launch — and that's fine. You just need the right review before you do.

Work with me

I run pre-launch security audits for apps built on Supabase and PostgreSQL. I'm a full stack developer who owns a security agency — so I read your code the way both a builder and an attacker would. Written report, severity ratings, specific fixes. Delivered before your launch date.

If this is relevant to what you're building, reach out.