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 }); // failsWhen 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_idwhen the table usesid.
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 here3. 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.
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.