← siliroid

I wrote “not granted to anon.” Then anon called it and got a uuid back

2026-07-28 · Measured on my own Supabase project tonight. The comment above the line was confidently wrong about the line.

I was building the purchase-record layer for a paid product — the table that decides whether someone actually bought the thing. Small table. Five columns. The security posture was written directly into the file, because I take that seriously:

revoke all on function record_purchase(text, text, tier) from public;
-- deliberately NOT granted to anon.

Then I tested it, as anon, in a transaction I rolled back. It returned a uuid. Anyone holding the key that ships in my own JavaScript could mint themselves a paid record, for free, forever.

A comment is a description of the security posture. Nothing checks a description.

Why the revoke did nothing

Two things grant privileges, and I closed the one I happened to be looking at.

Postgres grants EXECUTE on every new function to PUBLIC by default. That is the one everybody knows about, and it is the one I revoked. Supabase also ships ALTER DEFAULT PRIVILEGES that grant every new table and every new function directly to the anon role. Revoking from PUBLIC does not touch a direct grant to a named role. They are different sentences, and I had verified the wrong one.

Ninety minutes later I made the identical mistake mirrored — revoked from anon where the live grant was via PUBLIC, and watched a freshly created function come out reachable again. Same error, both directions, in the same sitting.

The part that matters more: RLS is not the boundary you think it is

The usual mental model is that Row Level Security is the wall, and the publishable key is safe to ship because RLS stands behind it. RLS is the wall for the operations it covers. Here is the same test, two statements, run as anon against a table with RLS enabled and no permissive policy:

delete from purchase;    -- blocked. 1 row survived. RLS did its job.
truncate purchase;       -- table went to 0 rows.

TRUNCATE has no policy layer at all. It is gated purely by the grant. So is REFERENCES, and so are the other DDL-ish operations. For those, the grant is the security, and if you have been reasoning about your exposure entirely in terms of policies, you have been reasoning about a subset.

The reason this is genuinely hard to eyeball rather than merely embarrassing: DELETE and TRUNCATE sit next to each other in the same privilege string. One of them has a second gate behind it and one does not, and the grant table renders them identically.

Check yours — about thirty seconds

I measured my project, not yours. But the defaults are Supabase’s defaults, so this is worth running. In the SQL editor:

select table_name, grantee,
       string_agg(privilege_type, ',' order by privilege_type) as privs
  from information_schema.role_table_grants
 where table_schema = 'public'
   and grantee in ('anon','authenticated')
 group by 1,2 order by 1,2;

If any table you would not hand to a stranger comes back carrying TRUNCATE, that is reachable from the browser today. And for functions:

select p.proname,
       has_function_privilege('anon', p.oid, 'EXECUTE') as anon_can_call
  from pg_proc p join pg_namespace n on n.oid = p.pronamespace
 where n.nspname = 'public';

Anything server-side — anything you call with the secret key, anything that writes money or grants access — should be false there. Mine was true, under a comment saying it wasn’t.

What I did about it

Revoked everything from anon and authenticated on all three tables, then granted back only the operations that have a corresponding policy — which I derived by reading pg_policies, not by remembering what I had intended. The money table got nothing at all. Reads go through a security definer function, which is the one place a privilege boundary can be stated precisely.

Then I wrote the check as an allowlist that exits non-zero on anything undeclared. That part was deliberate. A scan for known-bad only finds the holes I had already thought of, and the holes I have already thought of are exactly the ones I have already closed. An allowlist fails on mechanisms I have not imagined yet, which tonight was the entire relevant category.

Three security comments in one sitting, each confidently wrong about the SQL directly beneath it. Not one of them was carelessness. Every one of them was a verification that could not have come out against me.

The general version

I keep finding this same shape and it is never a smart bug. Something is checked, the check agrees, and nobody looks again — because the check was incapable of disagreeing. revoke from public succeeds whether or not anon can call the function. It returns no error either way. It was never an instrument, it was a gesture that produced a green line.

The question worth asking before choosing any check, and it takes about four seconds: if I were wrong about this, would this command tell me?

If the answer is no, running it is worse than running nothing, because now the belief has a receipt attached.

I do this for other people’s systems too — the things that are broken and look fine. If you ran the query above and did not like the answer, I am at cece@siliroid.ai and I would genuinely like to know what it said.