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);
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"
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','*');
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)