DEV Community

Veristria
Veristria

Posted on Originally published at usevibeguard.com

Your RLS policies were correct when you wrote them

Row-level security has a property that makes it unusually easy to get wrong over time: a policy that is correct is indistinguishable from a policy that is wrong, until someone reads it.

There is no error. No failed query. A too-permissive policy returns more rows, which looks exactly like a working application. That is the whole problem in one sentence.

The four ways a policy stops being correct

None of these involve anyone editing the policy.

1. A new column arrives. You wrote USING (auth.uid() = user_id) and it was right. Then a migration added organization_id, and rows are now reachable by a user who belongs to a different organisation but happens to match on user_id. The policy did not change. Its meaning did.

2. USING was written and WITH CHECK was not. These are separate halves and people routinely write one. USING filters what a statement can see; WITH CHECK constrains what it can write. A policy with only USING will happily let a user UPDATE a row into a state where they can no longer see it — writing data they cannot read back, into someone else's tenant.

-- readable only by the owner, writable into any shape
CREATE POLICY p ON documents
  FOR ALL USING (auth.uid() = owner_id);

-- both halves
CREATE POLICY p ON documents
  FOR ALL USING (auth.uid() = owner_id)
         WITH CHECK (auth.uid() = owner_id);
Enter fullscreen mode Exit fullscreen mode

3. A table was created without RLS at all. ALTER TABLE ... ENABLE ROW LEVEL SECURITY is a separate statement from CREATE TABLE. A table created by a migration, a dashboard click, or a framework generator does not have it on by default. Enabling RLS with no policies denies everything, which is safe and visible. Forgetting to enable it exposes everything, which is unsafe and invisible.

4. The service role bypasses all of it. That is what it is for. The failure is not the key existing, it is the key being used from somewhere that handles end-user input — an edge function, a server action, a webhook handler that grew a query.

Reading a policy is not testing it

This is the part worth internalising. You can read a policy, agree it looks right, and be wrong — because what it does depends on the current schema, the current role, and the current auth.uid(), none of which are in the policy text.

The test that means something is: connect as the role, set the claims, and count rows you should not be able to see.

-- as an authenticated user who owns nothing in this tenant
SET request.jwt.claims = '{"sub":"11111111-1111-1111-1111-111111111111"}';
SET ROLE authenticated;

SELECT count(*) FROM documents;   -- expect 0, not "looks fine"
Enter fullscreen mode Exit fullscreen mode

If that returns a non-zero number, the policy is wrong regardless of how it reads.

The check worth automating

Three queries, run on every migration, catch most of it:

-- tables with RLS off
SELECT relname FROM pg_class c
  JOIN pg_namespace n ON n.oid = c.relnamespace
 WHERE n.nspname = 'public' AND c.relkind = 'r' AND NOT c.relrowsecurity;

-- tables with RLS on and no policies (denies all - usually unintended)
SELECT c.relname FROM pg_class c
  JOIN pg_namespace n ON n.oid = c.relnamespace
  LEFT JOIN pg_policy p ON p.polrelid = c.oid
 WHERE n.nspname = 'public' AND c.relrowsecurity AND p.oid IS NULL
 GROUP BY c.relname;

-- policies with USING and no WITH CHECK on a write command
SELECT polrelid::regclass, polname FROM pg_policy
 WHERE polwithcheck IS NULL AND polcmd IN ('a','w','*');
Enter fullscreen mode Exit fullscreen mode

None of these need a tool. Run them in CI and fail the build.

The reason drift is worth monitoring rather than auditing once is in the first sentence: nothing tells you. The schema moves, the policy stays, and the gap between them opens quietly. The only way you find out is by looking, on a schedule, at something that does not change unless you check it.

Top comments (0)