TL;DR: The public anon key in your browser bundle is made safe by Row-Level Security (RLS) โ Postgres policies enforced on the server, not by hiding the key. Every table the anon key can reach should have RLS enabled and a policy. Enable it once, write owner-only policies that check current_setting('app.user_id', true), test as a signed-in user, and run the advisors before every ship.
This is the page to read before you put a backend in front of real users. It starts with the threat model โ why a public key is fine and what actually protects your data โ then gives you the one RLS pattern that covers most apps, copy-paste SQL for the common recipes, and the safety net that catches the mistakes everyone makes the first time. Lead thought: access is decided by policies you write in SQL, evaluated by Postgres on every query. Get those right and the rest follows.
When you wire up the SDK you ship two values into the browser: a data URL and a public anon key. The anon key is not a secret. It travels in your client bundle, it's visible in every request, and anyone can read it. That is by design.
Security does not come from hiding the key. It comes from Row-Level Security โ policies, written as ordinary Postgres SQL, that the database evaluates on the server for every single query before any row is returned. The key just identifies which backend you're talking to; the policies decide what that request is allowed to see and change.
This is why the rule is simple and absolute: every table the anon key can reach should have RLS enabled, with policies. A table is in exactly one of three states:
The mechanism behind the policies is worth understanding, because it's what makes the SQL you write correct.
current_setting('app.user_id', true). It's a TEXT value, and it is NULL when the request is signed out โ the SDK only sets it after sign-in. Cast it to your column's type in the policy (::uuid, ::text, and so on).apply_migration โ never as runtime calls.This is the pattern that covers most apps: a table where each row belongs to one user, and only that user can see or change it. Two steps โ enable RLS, then add the policy.
-- 1) Enable RLS. WITHOUT THIS, policies do nothing.
ALTER TABLE todos ENABLE ROW LEVEL SECURITY;
-- 2) Owner-only: a row is visible/editable only to the user who owns it.
CREATE POLICY "todos are private to their owner" ON todos
USING (user_id = current_setting('app.user_id', true)::uuid)
WITH CHECK (user_id = current_setting('app.user_id', true)::uuid);
Two clauses are doing two different jobs, and the distinction matters:
USING decides which existing rows are visible โ it filters SELECT, and gates which rows an UPDATE or DELETE is allowed to touch.WITH CHECK validates the rows you're allowed to write to โ it gates the new or changed row on INSERT and UPDATE. Without it a user could insert a row stamped with someone else's user_id.Note the cast: current_setting('app.user_id', true) returns TEXT, so you cast it to the column's type โ ::uuid here. And note what happens when the request is signed out: current_setting returns NULL, the comparison user_id = NULL is never true, no row matches, and access is denied. Signed-out safety is automatic.
A common first mistake is writing only USING. That makes existing rows private โ but an INSERT has no existing row to filter, so without WITH CHECK a client can create rows with any user_id it likes, including someone else's. For owner-only tables, write both clauses with the same condition. When they should differ (public read, owner write), use per-command policies โ see the recipes below.
Instead of trusting the client to send the right user_id, give the column a default that fills it from the signed-in user. The client never sets it at all:
ALTER TABLE todos
ALTER COLUMN user_id SET DEFAULT current_setting('app.user_id', true)::uuid;
Now insert({ title: 'Buy milk' }) with no user_id lands a row owned by whoever is signed in. Pair this with the owner-only policy above and there is no way for a client to claim a row it doesn't own.
For things like a blog or a public feed, anyone may read every row, but only the owner may create, change, or delete their own. Because the commands differ, write a separate policy per command with FOR SELECT / FOR INSERT / FOR UPDATE / FOR DELETE:
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "anyone can read" ON posts
FOR SELECT USING (true);
CREATE POLICY "owner can insert" ON posts
FOR INSERT WITH CHECK (user_id = current_setting('app.user_id', true)::uuid);
CREATE POLICY "owner can modify" ON posts
FOR UPDATE USING (user_id = current_setting('app.user_id', true)::uuid);
CREATE POLICY "owner can delete" ON posts
FOR DELETE USING (user_id = current_setting('app.user_id', true)::uuid);
The FOR SELECT policy with USING (true) makes every row publicly readable. The write policies each scope a single command to the owner. Splitting per command is the tool you reach for any time read access and write access aren't the same โ scope each command exactly as tightly as it needs to be.
You will get a policy wrong at some point โ everyone does. The backend ships an advisors scanner that reads your schema and flags the real RLS mistakes before they reach users. Among what it catches:
ENABLE ROW LEVEL SECURITY was never run โ so the table is wide open despite looking protected. Fix: ALTER TABLE โฆ ENABLE ROW LEVEL SECURITY;.search_path โ a security warning on functions whose search path can be manipulated.Run the advisors before every ship. It's the fastest way to catch the gap between "I wrote a policy" and "the policy is actually enforced." See Scan your backend with advisors.
RLS isn't only for one-shot queries. Realtime change events are re-checked against the same policies before they're delivered, so a subscriber only ever receives rows it could have read with a normal select(). You don't write separate rules for realtime โ get the table policy right and live updates inherit it.
ENABLE ROW LEVEL SECURITY and at least one policy.USING and WITH CHECK, casting current_setting('app.user_id', true) to the column type.user_id with a column default so clients can't spoof ownership.FOR SELECT / INSERT / UPDATE / DELETE policies.