My waitlist had one row in it — my own test address. Before telling anyone about the page, I audited it. The form was leaking the list.
Not the addresses. Something subtler and, for a waitlist, almost as bad: anyone could ask "is this specific person signed up?" and get a reliable yes or no. One request, one answer, repeat as needed.
Nothing in my code was wrong. That's the part worth writing down.
How a form leaks a list it can't read
The table was locked the way every Supabase tutorial tells you to lock it. Row Level Security on. One policy, insert only. No select policy, so nobody can read it back. I checked that part — reading the table from the browser returns nothing.
Then there's the column definition:
email text unique not null
unique is there for a good reason. Nobody wants a list where one person appears four times.
But PostgREST is a faithful translator. Post a duplicate and Postgres raises a unique violation, error 23505, and that comes back to the caller as a 409. A new address returns 201.
So:
201 → that address was NOT on the list
409 → that address WAS on the list
You never read the table. You ask it a yes/no question about one person at a time, and it answers honestly every time. Feed it a list of addresses and you learn which of them signed up for the thing.
For most tables that's harmless. For a waitlist it isn't, because membership is the sensitive part. Who is interested in a product before it launches is exactly the fact people assume isn't public.
The tell was sitting in my own client code:
// 23505 = duplicate. For the person typing, the result is the same:
// they're on the list. Showing an error would just confuse them.
if (error && error.code !== "23505") { ... }
I wrote that comment to be kind to a returning visitor. I was also documenting the leak and didn't notice.
The second finding: 56 privileges
While I was in there, I counted the table privileges granted to the two anonymous roles:
select table_name, grantee, count(*) as privileges
from information_schema.role_table_grants
where table_schema = 'public'
and grantee in ('anon', 'authenticated')
group by table_name, grantee
order by table_name, grantee;
Four tables. Two roles. Seven privileges each — select, insert, update, delete, truncate, references, trigger. 56 grants I never wrote.
They're the platform default, and RLS was blocking all of them. That's the design and it works. But it means RLS was the only thing standing between a visitor and truncate on the table holding everything I'd collected.
The distance between fine and catastrophic was one alter table ... disable row level security typed during a debugging session. One layer, no net.
The fix, which was a pattern I already had
The archive table in this project has been locked properly since day one: no direct access at all, one security definer function that knows how to do exactly one thing.
The waitlist never got that treatment because it was "just a form."
create or replace function entrar_na_lista(p_email text)
returns void
language plpgsql
security definer
set search_path to ''
as $$
declare v_email text;
begin
v_email := lower(trim(coalesce(p_email, '')));
if v_email !~* '^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]{2,}$' then
return;
end if;
insert into public.waitlist (email) values (v_email)
on conflict (email) do nothing;
end;
$$;
revoke all on table public.waitlist from anon, authenticated;
on conflict do nothing swallows the duplicate. returns void means new, duplicate and malformed all produce the same empty response. There's no signal left to read.
Then revoke the table privileges, so RLS stops being the only layer. Set search_path to empty on any security definer function — without it you've traded a small problem for a much larger one.
Measured against the live API afterwards:
POST /rpc/entrar_na_lista → 204
POST /waitlist (direct insert) → 401
GET /waitlist?select=email → 401
POST /rpc/entrar_na_lista (same email) → 204 ← identical
That last line is the whole fix. Same status for a new address and a repeat.
The privilege count across those four tables went from 56 to 2 — a select on the one table that holds no personal data and whose contents already appear on a public page. Everything else now goes through a function that knows how to do one thing.
What I'm not claiming
I didn't catch anyone doing this. There was one row in the table and it was mine. I found this by reading my own schema, not by finding it in a log.
This isn't a Supabase vulnerability. The unique constraint, the error code and the default grants all do exactly what they document. Every piece behaved correctly. The gap was in how they combine, which is a category of bug no single component can warn you about.
56 isn't 56 exploitable holes. references and trigger aren't much use to an attacker. The five that matter are select, insert, update, delete and truncate — still 40 grants I never asked for.
I don't know whether this matters at your scale. With one row it's theoretical. The reason I fixed it before launching is that the leak gets worse as the list gets more valuable, and the fix gets harder once a live form depends on the old path.
Run it on your own project
The privilege count is the query above. For the oracle, pick a table with a unique column and post a value you know exists:
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST "$SUPABASE_URL/rest/v1/your_table" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"a-real-address@example.com"}'
Run it once with an address that's in the table and once with one that isn't. If the two status codes differ, you have the oracle, and anyone who can guess an address can query your list one name at a time.
The order matters when you fix it: ship the function, deploy the client that calls it, then revoke the grant. Revoke first and you take down the form that's currently live.
I'm building HonestHook, which records what's trending so it can show what changed. It stores titles, scores and ranks — never authors or post bodies. The reasoning behind that is here, and what happens to an address you give it is here.