This week a security advisor flagged a table in one of our own production databases with Row-Level Security switched off. We fixed it in minutes. The interesting part is everything we found around it — a second exposure the alert never mentioned, a three-hour delay caused by an inbox rather than a firewall, and a forensic answer we could not honestly give.
What "RLS disabled" actually means
Postgres tables in a Supabase project are reachable through a REST API. Row-Level Security is the thing that decides which rows a given caller may see. With RLS enabled and no policies, the default is deny — nobody gets anything. With RLS disabled, the table is simply open to whoever can reach the endpoint.
Not "readable by logged-in users." Not "readable by your app." Open. The anonymous key is
published in your front-end by design, so in practice the audience is everyone. And it is not
read-only: INSERT, UPDATE and DELETE come with it.
That last part is the half people underestimate. Our exposed tables held almost nothing — one prospect record, and a subscriber table that was still empty. The read risk was close to zero. The write risk was not: anyone could have injected rows into a table that will later hold paying customers' Stripe IDs, or quietly deleted what was there.
This is not a rare mistake
It is one of the most common serious findings in the ecosystem. Precursor Security, reporting on six months of testing Supabase applications, found that the most common critical issue was not a novel exploit but a misconfigured RLS policy exposing every row. Practitioner write-ups put RLS misconfiguration at the centre of the large majority of Supabase security incidents, with no RLS at all as the worst case.
Supabase clearly agrees, because they are changing the default. Their changelog entry of 28 April 2026 states that "New tables in the public schema will no longer be exposed to the Supabase Data API by default" — opt-in immediately, the default for new projects from 30 May 2026, and enforced on all projects from 30 October 2026.
If you run a Supabase project, that last date is a real deadline, and it is worth knowing before it arrives rather than after something breaks.
The second exposure was the worse one
The advisor flagged two tables. While fixing them we checked whether anything else was reachable — and found a view serving real internal business data to anonymous callers. Nine rows describing which patents and which holders we had been evaluating for acquisition, including a field flagging which looked sellable.
It had not been flagged, and could not have been, because a view has no Row-Level
Security of its own. This one was declared SECURITY DEFINER, meaning it
executes with its creator's privileges and reads straight through the RLS on the
underlying tables. A check named "RLS disabled on a public table" is structurally incapable of
seeing it.
We tested all eleven SECURITY DEFINER views in the database individually. Exactly
one was reachable. Had we trusted the alert's scope, we would have fixed what we were told about
and left the more sensitive leak in place.
That is the transferable lesson. An alert tells you what one check looked for. It does not tell you what you are exposed to. Those are different questions, and only the second one matters.
Three hours, and it was not a technical failure
The advisor email arrived and sat for about three hours before a human noticed. Nothing was down. The monitoring worked exactly as designed — which was the problem.
Our inbox monitor classified mail one way: bulk versus personal. A critical security alert is not bulk, so it became a single line in a digest, rendered identically to a note from a friend. There was no concept of severity anywhere in the code. On top of that it polled every two hours, so up to two of those three hours were structural.
This is worth stating plainly because it is common and it is invisible until it costs you: many teams have alerting that is technically functioning and practically useless, because the alert arrives with the same weight as everything else. A channel that treats a breach notice and a newsletter identically has not alerted you. It has filed something.
We built a separate fast lane that matches security and infrastructure senders, polls every fifteen minutes, sends one un-batched message per alert, and shouts if it breaks. Tuning it taught us something too: the first version flagged 14 of 56 real messages, and 13 were job-application notices titled "[Action required] New application for…". Generic urgency words matched on subject alone. A lane that noisy gets muted within a week, which would have left us worse off than before it existed. Requiring a known vendor for generic phrases took it from 14 to 1.
The question we could not answer
The obvious next question is whether anyone actually reached the data before we closed it. We queried the logs. A seven-day window returned zero hits for those objects, which reads like a clean bill of health.
It was not one. A one-hour window using the identical filter returned six hits — our own test requests, which we knew had happened. A control query over the same seven-day window returned 576,922 events, so the data was there. The long-window query was silently returning nothing instead of scanning.
So the honest answer is: we do not know. No evidence of unauthorised access was found, and that absence is worth nothing, because the instrument was broken. It matters because automated scanners used by threat actors are reported to find newly exposed endpoints within seconds of them going live.
If you take one habit from this post, take that one: before you accept a zero as good news, prove the query can return a non-zero. A forensic tool that quietly fails returns exactly the answer you were hoping for.
What actually fixes it
- Enable RLS on every public table, even ones you think are empty or internal. RLS on with no policies is default-deny, which is the safe state. Adding policies only ever widens access — write them when something genuinely needs them.
- Check who owns the table before you plan the fix. Ours were owned by
postgres, so our normal database role could not runALTER TABLEat all. Discovering that mid-incident is a bad time. - Audit views separately from tables. They have no RLS, and a
SECURITY DEFINERview reads through the protection on everything beneath it. - Test both directions. Confirm an anonymous request is denied and that authorised access still works. We verified reads returned empty, writes returned an explicit RLS violation, and our own scheduled job still ran clean.
- Give critical alerts their own channel. The most transferable fix here was not a database change. It was making sure a security alert can never again look like marketing.
The same question in three different places
We spend most of our time on evidence problems — verifying that something is actually true rather than assuming it. This incident was the same question wearing different clothes: what are you actually exposed to, not what did one check report. It is the reason our LLM broker exposure scanner reports any endpoint that is not a documented provider endpoint, rather than matching a list of known-bad domains: a blocklist only ever finds what someone already catalogued.
Both exposures described here are closed, verified with real anonymous requests, and confirmed resolved by the platform's own security advisor. All four of our projects were checked, not just the one that was flagged.