Day 12 of 23 · Thursday · Learning

Row-Level Security — why your data can't leakConcept

Row-Level Security is the per-row bouncer inside Postgres. The Focaccia vote page shipped its database key to every dinner guest's phone, and four lines of RLS policy are the entire reason none of them could delete the votes.

Catch-up progress
12/23
Why this matters to you

When Focaccia Vote went live, the Supabase anon key sat in the page source of a public URL, loaded by every guest's phone. The only thing standing between any of those phones and DELETE FROM votes was the policy block in live/setup.sql — there is no other lock. It held by design, not luck, and today you read exactly why.

Classic database permissions work per-table: a user either can or can't touch the votes table at all. Row-Level Security (RLS) moves the decision inside the table — every read, insert, update, or delete gets checked against per-operation rules called policies, row by row. The crucial behavior: the moment you run ALTER TABLE ... ENABLE ROW LEVEL SECURITY, the table flips to default-deny. With zero policies, the API sees an empty table and writes nothing. Policies don't restrict an open table; they punch specific holes in a sealed one.

Supabase gives every project two keys, and the difference is the whole security model. The anon key is a public identity — it's designed to be embedded in web pages, and every request made with it is filtered through the RLS policies. The service_role key is the master key: it bypasses RLS entirely. That's why the anon key sitting in vote.html is fine, but the service key appearing in any browser-delivered file would mean every guest could wipe the table. Public key + tight policies is the intended pattern, not a compromise.

In your workspace this lives at JARBUS/projects/plaud-2ee4b9a7e2b4988b905fd8b897a698e2/live/setup.sql. The votes table gets exactly two policies — anon_insert and anon_select — and deliberately no UPDATE or DELETE policy. That absence is load-bearing: it's why the README says 'anon DELETE blocked by design (clear via SQL Editor)' and why resetting between dinner events requires you, in the dashboard, rather than anyone with the URL.

Worked example

This is the real gate, verbatim from live/setup.sql, plus the query that shows what's actually live in Postgres.

-- live/setup.sql — the actual Focaccia Vote gate
ALTER TABLE votes ENABLE ROW LEVEL SECURITY;

CREATE POLICY "anon_insert" ON votes
  FOR INSERT WITH CHECK (true);

CREATE POLICY "anon_select" ON votes
  FOR SELECT USING (true);

-- Inspect what's live (run in the Supabase SQL Editor):
SELECT policyname, cmd, qual, with_check
FROM pg_policies
WHERE tablename = 'votes';
▶ Do it now
  1. Open the real file: /Users/tom/Claude/JARBUS/projects/plaud-2ee4b9a7e2b4988b905fd8b897a698e2/live/setup.sql — read section 1 (lines 7–21). Today's hands-on: say in ONE sentence what the votes policies permit. (Check yourself: 'Anyone holding the anon key may add a vote and read all votes — and nothing else.')
  2. Open the Supabase dashboard → project wwddxjaafmktzvypjcyy → SQL Editor, and run the pg_policies query from the worked example. Confirm exactly two rows come back for votes: cmd INSERT and cmd SELECT. File says intent; this confirms enforcement.
  3. Prove the deny. In the same SQL Editor run: BEGIN; SET LOCAL ROLE anon; DELETE FROM votes; ROLLBACK; — the result line reads 'DELETE 0'. You just impersonated the public identity and asked to wipe the table, and RLS made every row invisible to the delete (the BEGIN/ROLLBACK wrapper guarantees nothing sticks either way). That zero is the leak-proofing, observed live.

Gotchas

Go deeper: Supabase — Row Level Security guide · Postgres — CREATE POLICY reference · Supabase — understanding API keys (anon vs service_role)
One-card takeaway

The anon key is public by design — RLS policies are the only lock, and the rights you don't grant are the security.