Docs / Search / LingCode Cloud / Security & RLS
๐Ÿ“˜ Reference โ— Read before you ship Updated 2026-06-11

Security & Row-Level Security

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.

Why this matters: the threat model

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 anon key is public by design โ€” never rely on it being secret. Anything a signed-out visitor shouldn't see must be blocked by a policy, not by the key being "hard to find." And never put a private vendor key (Stripe secret, third-party API keys) in a table or in the client โ€” those go in the Secrets vault and are read only from server-side functions.

How enforcement works

The mechanism behind the policies is worth understanding, because it's what makes the SQL you write correct.

The owner console and migrations run as admin and BYPASS RLS. When you query from the console you see everything, regardless of policies โ€” so the console is the wrong place to test access rules. Always test policies as a signed-in app user (and as a signed-out one), never from the console, or you'll convince yourself a broken policy works.

The core pattern

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:

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.

Why USING and WITH CHECK both appear

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.

Recipes

Auto-stamp the owner (so clients can't spoof it)

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.

Public read, owner write

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.

The safety net: advisors

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:

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.

Realtime respects the same policies

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.

Putting it together: a ship checklist