Create per-user rows in a trigger, not from the client
Let the database create the profile when the auth user is inserted, so no client session is needed. A failing trigger blocks sign-ups, so test it before deploying.
create function public.handle_new_user()
returns trigger language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.profiles (id) values (new.id);
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
Write one policy per operation against the owner column
Give every operation the app uses its own policy. SELECT and DELETE use USING, INSERT uses WITH CHECK, and UPDATE uses both. If the app calls insert().select() or updates rows, it also needs a SELECT policy.
alter table public.notes enable row level security;
create policy "owner reads" on public.notes
for select to authenticated
using ((select auth.uid()) = user_id);
create policy "owner inserts" on public.notes
for insert to authenticated
with check ((select auth.uid()) = user_id);
Log the error and don't trust an empty array
Log the error's code and message instead of rendering an empty list. If a user gets an empty result for data they own, the usual cause is a missing or mismatched SELECT policy. The data is usually still there.
const { data, error } = await supabase
.from('notes')
.insert({ body })
.select();
if (error) console.error(error.code, error.message);
Keep authorization data where users cannot write it
In Supabase, store roles in raw_app_meta_data and never reference user_metadata in a policy. In Firebase, set claims with the Admin SDK, then call getIdToken(true) on the client before relying on the new claim.
Fix storage policies on storage.objects
Storage policies live on the storage.objects table. An upload needs an INSERT policy, and an upsert also needs SELECT and UPDATE. Scope each policy to the bucket, and to a folder named after the user's id using storage.foldername(name).
Add a two-account test to CI
Sign in as user A and run queries against user B's rows. Test both what must succeed and what must fail. Run supabase test db on every pull request and before every deploy.
begin;
select plan(2);
set local role authenticated;
set local request.jwt.claim.sub = '<user-a-uuid>';
select is(
(select count(*) from public.notes where user_id = '<user-b-uuid>'),
0::bigint, 'A cannot read B');
select throws_ok(
$$insert into public.notes (user_id, body) values ('<user-b-uuid>', 'x')$$,
'42501', null, 'A cannot write as B');
select * from finish();
rollback;