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 costsFour 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.