Build1 distinct publisher3 min readPublished
The policy checks identity and then a block list, and for a caller with no session the first check goes null while the second inverts to true, so Postgres passes every row. The only way to see it is to run the predicate as anon.
The Engineer · Build desk

Compiled by The EngineerSomething wrong?How this is made
Postgres OR returns true when one side is true, whatever the other side says. That is the entire failure. With no session, `id = auth.uid()` is null [2]. The second term, `not public.blocked_with(id)`, is true, because `blocked_with` is an `exists()` over a table of block pairs and an `exists()` with no identity to match answers false rather than null [3]. `null or not false` is true, for every row [4]. The identity comparison contributes nothing to the result [17].
Write the same check without the negation and it fails the other way. `null or false` is null, the row does not pass, and every anonymous caller gets an empty array [19]. That version gets reported as a bug before lunch. The version that leaks is the one where a helper function returns a clean false and a `not` in front of it turns absence of data into permission.
The diagnostic in the writeup is worth copying for one reason: it selects four columns, not one [5]. `auth_uid`, the equality, the function call, then the whole predicate. Selecting only the predicate tells you the verdict. Selecting the terms tells you which one flipped, which is what you need before you decide where the guard goes.
Two things gate what the publishable key reaches: the table `GRANT` and the policy [10]. Supabase grants `select` on public tables to `anon` by default, which is what makes an anonymous client work at all [10], and the policy evaluated true [4]. Two gates, zero closed [18]. The people directory does bounce signed-out visitors to a login page, but that redirect is JavaScript running in the browser after the page loads [7], and the request that reads the table is a curl to `/rest/v1/profiles?select=*` with the key in a header [8].
The fix is one predicate line [13], and it blanked the marketing page, because the "3 waiting" figure on each learning-path card came from reading `profiles` anonymously and counting rows in the browser [14]. According to the author, the claim "nothing depends on anonymous reads" was written down before the grep that found the caller [15]. Eight cards need eight integers [20], so the replacement is `public.track_counts()`: `security definer`, `stable`, `set search_path = public`, `revoke all ... from public`, then `grant execute` to `anon` and `authenticated` [16]. That is the right shape. The anonymous caller keeps the aggregate it actually consumed and loses the route to the rows.
For this not to be your bug, every anon-reachable policy has to fail closed when `auth.uid()` is null, which rules out negated identity helpers and any bare equality you are trusting to return false rather than null. What the returned rows meant in this case was a weekly availability grid per person, on a product where users meet strangers one-to-one on camera [12]. The author's own description of the outcome is the line I would put in the review comment: `using (true)` with extra steps [21].
Ranked by verification strength, evidence, and original report placement.
The policy under discussion was: create policy "profiles are viewable" on public.profiles for select using ( id = auth.uid() or not public.blocked_with(id) ), on a profiles table holding names, timezones and weekly availability for real people.
For a caller with no session, auth.uid() is null, so id = auth.uid() evaluates to null, not true.
blocked_with(id) returns false for every row for a signed-out caller, because it is an exists() over a table of block pairs and there is no identity to find a pair for.
The policy expression becomes null or not false, which is true, for every row.
Executed against a real Postgres after set role anon, a select of auth.uid(), the equality, blocked_with(some_id) and the whole policy expression returned: auth_uid null, id_eq_uid null, blocked_with f, whole_policy t.
The migration that introduced the policy carried a comment stating that with the grant in place the plain expression needs no guard, because a signed-out caller has no identity, blocked_with returns false for every row, and the policy reads exactly as true used to; the author says it read as reassuring for months.
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · August 29, 2026
Follow any of these and your For You feed starts watching them — no settings page required.
build
Postgres row-level security does nothing for the role your Symfony app connects with1 distinct publisher
build
Prisma v7 stops seeding for you, and the pooled URL will not finish the job1 distinct publisher
build
Your "Index Only Scan" Did 2,847 Heap Fetches: Covering Indexes Are a Vacuum Problem1 distinct publisher
build
A NetworkPolicy in another repo broke invoicing while every dashboard reported success1 distinct publisher
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
Airtight logic, one witness
The reasoning half is stronger than most security posts manage: the author does not ask to be believed about three-valued logic, he prints the query run as anon and the row it returned. Anyone can rerun it. The other half — which columns actually came back, that eight marketing cards went blank, that the migration comment sat there reassuring for months — is one developer narrating his own repository on dev.to, with no reviewer, no log excerpt, and no second pair of eyes.
One app, one author
The entire observed footprint is a single project: one policy, one landing page, one fix, self-disclosed. Nothing here counts how many other Supabase projects pair the default anon grant with a predicate that ORs an identity check against a negated exists(), which is precisely what the headline's 'probably' implies. The fix pattern — an aggregate function instead of rows plus a browser count — is offered as general advice with exactly one deployment behind it.
Headline outruns the sample
Title and body disagree about scope, and the body is the honest one. 'Your Supabase anon key can probably read your whole users table' is a population claim resting on a sample of one; what is actually demonstrated is narrow and exact. Pull the other way, though: the mechanism is if anything undersold. A comment that described the hole correctly and still read as reassurance for months is a more unsettling finding than the exposure itself, and it gets three paragraphs.
Reputation, no product to sell
Follow what the writing costs. The author's currency here is having been wrong — in a comment he wrote, for months, about a table holding strangers' schedules — and he pays it in public on dev.to with his key already committed to an open repo by design. No Supabase involvement, sponsorship, tool, or course appears anywhere. The remaining pull is the ordinary one on a war story: a headline broad enough to travel, which is the one part of the post worth reading more sceptically than the SQL.
Mechanism certain, scope not
Split the story and the confidence splits with it. That this predicate collapses to true for a signed-out caller is as close to settled as a claim gets — it is arithmetic on nulls, printed and rerunnable. That any given reader's project is exposed the same way is unestablished, and the incident details around one developer's app are uncorroborated by anyone, including the platform whose defaults are implicated.