HeHeedza

Why does signup fail with "new row violates row-level security policy for table profiles"?

Last reviewed

Short answer

Your app inserts the profile from the browser right after signUp(), but with email confirmation on, the user has no session yet. So auth.uid() is null and your insert policy rejects the row. Create the profile in a security definer trigger on auth.users and remove the client-side insert. Don't disable RLS.

Why this happens

A common AI-generated signup does this:

const { data } = await supabase.auth.signUp({ email, password });
await supabase.from('profiles').insert({ id: data.user.id, username }); // fails

When Confirm email is enabled, signUp() returns a user but no session. The next request goes out as the anon role, so auth.uid() is null, and a policy like with check (auth.uid() = id) blocks it. That's exactly what RLS should do.

Other versions of the same error:

  • upsert() needs both INSERT and UPDATE policies.
  • insert(...).select() also needs a SELECT policy, because the row is read back.
  • The policy compares the wrong column, for example user_id when the table uses id.

How to fix it, step by step

1. Create profiles on the server side with a trigger. This runs inside Supabase whenever a user is created, whether or not they have a session:

create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
  insert into public.profiles (id, username)
  values (new.id, new.raw_user_meta_data ->> 'username')
  on conflict (id) do nothing;
  return new;
end;
$$;

drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

2. Pass profile fields as signup metadata instead of inserting them yourself:

const { error } = await supabase.auth.signUp({
  email,
  password,
  options: { data: { username } },
});
// no profiles.insert here

3. Keep RLS on, with policies for what the app really does:

alter table public.profiles enable row level security;

create policy "Read own profile" on public.profiles
  for select to authenticated using ((select auth.uid()) = id);

create policy "Update own profile" on public.profiles
  for update to authenticated
  using ((select auth.uid()) = id)
  with check ((select auth.uid()) = id);

Supabase recommends wrapping auth.uid() in (select ...) for performance. You don't need an INSERT policy for the trigger, because it runs as the function owner.

4. Remove the "quick fixes" the AI may have added. Delete any policy like for insert to anon with check (true), and re-enable RLS if it was turned off. Those let anyone on the internet write to your table with the public key.

5. Backfill users who signed up while it was broken:

insert into public.profiles (id)
select u.id from auth.users u
left join public.profiles p on p.id = u.id
where p.id is null;

6. Test a signup in a private window with confirmation on, confirm the email, then load the profile page.

Fix prompt for your AI builder

Paste this into Lovable, Bolt, Cursor, Claude, ChatGPT, Replit, v0 or whichever tool you build with. Replace the bracketed parts first.

Fix prompt
Signup fails with "new row violates row-level security policy for table profiles" because we insert the profile from the client before the user has a session. Please:
1. Remove the client-side profiles insert or upsert after signUp. Pass username and other profile fields in signUp options.data instead.
2. Add a migration: a security definer handle_new_user() function with set search_path = '' that inserts into public.profiles from new.raw_user_meta_data, plus an after insert trigger on auth.users.
3. Keep RLS enabled. Add select and update policies scoped to (select auth.uid()) = id for the authenticated role. Remove any policy that lets anon insert.
4. Add SQL to backfill missing profiles.
Don't disable RLS and don't change the signup UI.

How to catch this next time

RLS and signup bugs often appear after an AI edit "fixes" something else, or after you switch on email confirmation for launch.

Heedza checks on a schedule that your live signup page loads and shows its email field, and it emails a screenshot and a fix prompt when it doesn't. It never creates accounts, so do one real signup after any policy change. Its passive security scan also flags a Supabase service_role key showing up in your public JavaScript, a common side effect of "just make the insert work" fixes. Try the free scan on your live URL.

Questions

Can I just turn off email confirmation?

That gives users a session immediately, so the client insert would pass, but it lets anyone sign up with someone else's address. The trigger approach works either way and is safer.

Why not use the service_role key to do the insert?

That key bypasses all RLS. In a browser app it would be visible to everyone. Keep it, or the newer sb_secret_ key, only in server code or Edge Functions.

Why does upsert fail when insert works?

Upsert can update an existing row, so Postgres also checks your UPDATE policy. You need both policies, or use the trigger and skip the client write entirely.

Sources