RLS policies do not fail. They drift.
A row-level security policy that was correct when you wrote it can stop covering what it was written for, without erroring, without changing, and without anyone noticing.
Row-level security has an unusual failure mode. A policy does not break in the way code breaks. It does not throw, it does not fail a test, it does not appear in an error log. It keeps running exactly as written, and at some point what it was written for stops being what it does.
The schema moved. The policy did not.
The shape of the drift
Here is a policy that is completely correct on the day it is written.
create policy "own_documents"
on documents for select
using (owner_id = auth.uid());
Every row in documents has an owner. A user reads their own. Nothing else. Correct.
Three months later the product grows a sharing feature. A document_collaborators table appears, and a new query joins it. The policy above is still there, still parses, still runs — and now the team writes a second policy to cover the new access path, because the first one does not.
Two months after that, someone adds an organisation_id column to documents and a dashboard that lists documents by organisation. The dashboard returns nothing, because own_documents filters by owner_id. So a third policy is added for organisation members.
Nothing here is negligent. Every step was a reasonable response to a real requirement. But documents now has three overlapping select policies, and Postgres combines multiple permissive policies with OR. The effective access rule is the union of all three — which is not what any of them says on its own, and is not something anybody has written down.
That is drift. Not a broken policy: a set of individually correct policies whose combination nobody has evaluated.
The four ways it happens
In practice, almost all of it comes from four events.
A new table ships with no policy at all. If RLS is not enabled on a table, the anon key reads it. This is the loudest version of the problem and still the most common, because enabling RLS is a separate step from creating the table, and a table created by a migration written at speed does not always get both.
A column widens what an existing policy exposes. The policy filters rows, not columns. Adding a column to a table that is already readable makes that column readable too — including the one holding an email address, an internal note or a partially masked identifier.
A new access path routes around the policy. A view, an RPC or a security-definer function that queries the table on the caller's behalf. Postgres views do not inherit the underlying table's RLS unless they are explicitly set up to; a security-definer function bypasses it by design. Both are legitimate tools, and both are ways for a row to arrive somewhere the policy never approved.
Policies accumulate. As above. Each addition is safe in isolation and the union is never reviewed.
Why reading the migrations does not catch it
The instinct is to review this statically: read the policy files, reason about the schema, check the logic. It does not work well, for the same reason reading code is a weak substitute for running it.
A policy's behaviour depends on the current schema, the current set of other policies on the same table, the current role, and the current contents of auth.uid(). Reasoning about the union of three policies against a schema that has changed twice since they were written is exactly the kind of thing humans get wrong — and it has to be redone every time any of those inputs changes.
The alternative is to ask the database. Not "what does this policy say", but "as this role, with this token, can I read this row". That question has a definite answer, it is cheap to ask, and it is correct by construction, because it is the same code path a real request takes.
What to check, in order
If you want to do this by hand today, this is the order that finds the most in the least time.
- Every table with RLS disabled. Query
pg_tablesforrowsecurity = falsein your public schema. Each one is readable by anyone holding the anon key, which is in your frontend bundle. - Every table with RLS enabled and no policies. This is the reverse failure: RLS on, no policy, everything denied. Usually it produces a bug report rather than a breach, but it tells you the table was set up in a hurry.
- Tables with more than one permissive policy for the same operation. These are the union cases. Write down what the combination actually permits.
- Views and security-definer functions that touch protected tables. Check each one for whether it is enforcing RLS or bypassing it.
- Then test it. For each protected table, hold an anon token and a token for a user who should not have access, and try to read a row. The result is the only answer that counts.
The point
RLS is a good mechanism. It is enforced in the right place, it is hard to bypass by accident from application code, and it survives a careless client.
What it does not do is notice when the world around it changes. The policy you wrote in April is still doing precisely what you asked it to in April. Whether that is still the right thing is a question somebody has to keep asking — and the honest way to ask it is to run the query and look at what comes back.
VibeGuard is an independent product with no affiliation to, or endorsement from, Supabase.