Can the database keep a private note private? · slopware 101

slopware 101 · step 19 of 35

Can the database keep a private note private?

by · 4 min read

The previous chapter put the ownership check in your server's query. Some stacks move that check one layer down, into the database itself, and Supabase is the common example. The browser talks to a Data API that sits in front of Postgres, and there may be no server code of yours in between. That changes where the question of whether this row is yours has to be answered.

Start with the key, because it is the part that looks alarming. A Supabase project gives you a public key, called anon or publishable, and it belongs in the browser. It is not a secret, and finding it in your JavaScript is not a leak. Three things together decide what it allows:

  • which schemas the Data API exposes (by default, public);
  • which SQL grants the API's roles have on each table (requests from a browser run as anon before login and as authenticated after);
  • the row level security policies on those tables.

The other kind of key, service_role or a secret key, is different. It maps to a role that bypasses row level security entirely, so it belongs on a server and nowhere else. Table owners also bypass row level security unless the table is set to force it.

What RLS does

Row level security (RLS) is a Postgres feature. Once you enable it on a table, every query from a role subject to it is filtered through the table's policies. For the simple permissive policy below, a row comes back only when its condition is true. If you later combine policies, permissive rules can widen access while restrictive rules further narrow it; review their combined effect.

Tables created in Supabase's Table Editor have RLS enabled for you. Tables created through raw SQL need explicit configuration. In a disposable Supabase project, create this separate example table and enable its protection before exposing any rows:

create table public.private_notes (
  id bigint generated always as identity primary key,
  user_id uuid not null references auth.users(id),
  title text not null
);
alter table public.private_notes enable row level security;
grant select, insert, update, delete
  on public.private_notes to authenticated;

The grant permits these operations at the table level. RLS still needs a policy to permit their rows. If your own schema has different grants, inspect them rather than copying this exercise over a live table.

If you turn it on without writing a policy, ordinary roles see nothing. Every list in your product goes empty, your screens look broken, and the quickest way to fill them again is to turn RLS back off. So the safe state arrives disguised as a regression, and the dangerous state looks like a finished product. Disabling RLS is never the fix. The fix is to write the policy you were missing.

A mental model for one policy

This Supabase example uses a separate private_notes table whose user_id is a UUID referencing auth.users(id). It does not reuse the earlier notebook's numeric user ids. The table and grants above prepare the example; the read policy below adds the ownership rule. The simplest read rule says you may see a row if its user_id is you.

create policy "read own notes"
on private_notes for select
to authenticated
using ( (select auth.uid()) = user_id );

auth.uid() is the id from the logged-in user's token. You can read the policy as a filter that Postgres quietly adds to every select: where user_id = me.

That policy covers reading and nothing else. Inserting needs its own policy with a with check clause. Postgres checks that clause against the new row, so nobody can create a note carrying someone else's user_id. Updating needs using, which decides which rows you may touch, and with check, which decides what they may become. Deleting needs its own using. An operation with no matching policy is denied. That is safe, but it tempts you to add a loose policy later just to make an error go away.

Pause here and look at the policies you already have. Does any of them say auth.role() = 'authenticated', or using (true)? The first lets every logged-in stranger through. The second lets through everyone the policy applies to. Both are real policies, both make the error go away, and both answer who rather than whose.

Checking your own project

Ask Postgres which tables in the public schema have RLS off:

select tablename
from pg_tables
where schemaname = 'public' and rowsecurity = false;

Treat the result as a list to review, not as proof of public access. A table without RLS might have no grants for anon or authenticated. A table with RLS might have a policy that is too wide. You have to read schema exposure, grants and policies together.

Next, run the two-account test from the previous chapter against your own project. Create users A and B and give each a few rows. Then, as B, try to select, insert, update and delete A's rows through the same client library your app uses. Test through ordinary user sessions as well as anonymously. An administrative SQL session or bypass key cannot demonstrate the protections applied to the browser client.

Finally, keep your policies in committed migrations. A dashboard-only change can be inspected there, but it is absent from the repository's review and reproduction path. Migrations let another environment receive the same policy deliberately.

References