Skip to content
All writing

Writing

A publishable key is a write credential

The Supabase grant that made a contact form work also published an insert credential to the internet. Every control in front of it was decorative.

4 min read · postgres · security · supabase

The contact form on this site writes one row to one Postgres table. That is the entire feature. It still managed to ship a credential to the internet, and the way it did is worth writing down, because the mistake looks like the documentation.

What it looked like

Since May 2026, new Supabase projects no longer grant table access to the anon role automatically. So a table with row-level security enabled and an insert policy attached is a table the browser client cannot write to — the policy is correct and does nothing. The fix is the missing grant, and the careful version of it is column-scoped:

grant insert (
  name, email, company, project_type, budget_range,
  timeline, message, heard_from, source, ip_hash, user_agent
) on public.leads to anon;
 
create policy leads_anon_insert on public.leads
  for insert to anon
  with check (true);

That is a deliberately narrow grant. anon holds no privilege to write status or notes, which are mine. It is enforced by Postgres rather than by application code. As grants go it is a good one.

In front of it sat a real stack of controls: a Cloudflare Turnstile challenge, a honeypot field, a submission-timing check, Zod validation of every field, and a WAF rate limit at the edge.

Why none of that mattered

The publishable key is in the client bundle. That is what publishable means — it is compiled into the JavaScript that every visitor downloads, and no amount of care changes that, because the browser has to authenticate somehow.

So the request path the controls guard is not the only request path. It is just the polite one:

curl -X POST 'https://<project>.supabase.co/rest/v1/leads' \
  -H "apikey: $PUBLISHABLE_KEY" \
  -H "Authorization: Bearer $PUBLISHABLE_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"name":"x","email":"x@example.com","message":"..."}'

No Turnstile, because Turnstile is verified in my code and this never reaches my code. No honeypot, no timing check, no Zod, no WAF rule — the WAF sits in front of my deployment, and this request is not addressed to my deployment.

Every control I had listed was a property of one client, and the grant is a property of the database. The two lists never overlapped.

The part that made it expensive

The Supabase free tier gives you 500MB. Postgres text is unbounded, so the first thing to check was whether a single request could fill it. It could, which is why length constraints went in early:

constraint leads_message_len check (char_length(message) between 10 and 4000)

That reads like it caps a message at 4KB. It caps it at 16KB, because char_length counts characters and storage is measured in bytes:

select char_length(repeat('𝄞', 4000)),   -- 4000  → passes the check
       octet_length(repeat('𝄞', 4000));  -- 16000 → what it actually costs

Four bytes per codepoint, four times the budget, constraint satisfied. So alongside every character cap there is now a byte cap, which is the one that means what I thought the first one meant.

Row count was worse, because nothing bounded it at all. Roughly nineteen thousand full-size rows fill the tier. Over quota the project goes read-only, and the only recovery that actually reclaims disk is TRUNCATE — which destroys every real lead along with the junk. An attacker does not need to read anything to do real damage here.

What actually fixed it

Not more validation. The grant.

revoke all on public.leads from anon;
drop policy if exists leads_anon_insert on public.leads;

Writes now go through the secret key from a Server Action, behind import 'server-only', so there is exactly one path into the table and it is one I control. Turnstile, the honeypot, the timing check and Zod all became load-bearing at the moment the direct path stopped existing.

That trade is worth stating honestly rather than glossing: the secret key bypasses RLS and is not column-scoped, so the property I liked — the web tier physically cannot write status — is gone. It is now a discipline enforced by code review rather than by Postgres. I traded an enforced-but-bypassable control for an unenforced-but-unreachable one, and on this table that is the right way round, because the bypass was public and the discipline is not.

Then a BEFORE INSERT trigger, because it is the only layer that binds every path — RLS, secret key, and me in the dashboard alike:

create trigger leads_rate_guard before insert on public.leads
  for each row execute function public.leads_insert_guard();

Twenty rows an hour, and a hard ceiling on table size. A trigger is not a rate limiter in any sophisticated sense. It does not need to be. It needs to be the thing that is still true after someone finds a path I did not think of.

The generalisation

Anything the browser holds, an attacker holds, with the network stack of their choosing and none of your UI. That is not a new observation. What made it easy to miss here is that the credential arrived through a well-documented feature working exactly as designed, and the code around it looked defensive.

The question worth asking of any client-side key is not "what does my app do with this." It is: what can someone do with this and curl? For a column-scoped insert grant the honest answer was "fill your database," and every control I would have pointed at in a review lived on the wrong side of the question.

One follow-on, which I only caught because I ran the advisors immediately after the migration: creating that trigger function published a new RPC endpoint. Postgres grants execute on new functions to public by default, so my guard was itself callable at /rest/v1/rpc/leads_insert_guard before I revoked it. The tool that caught the fix needed fixing too.