Artículo
Supabase "new row violates row-level security policy": seis causas y el arreglo seguro
Publicado el
Pulsas guardar, o subes una foto, o terminas de registrarte, y la app no muestra nada. En la consola del navegador o en la pestaña de red hay un 403 con esto:
new row violates row-level security policy for table "profiles"
con el código 42501. El nombre de la tabla cambia (profiles, orders,
products, objects para subidas a Storage), pero la causa suele ser una de
seis.
El error significa que Row Level Security está activado en esa tabla y ninguna política ha permitido esta escritura concreta para este usuario concreto. Eso es RLS haciendo su trabajo. El arreglo es conseguir que encaje la política correcta, no que encajen todas.
Primero, el arreglo equivocado
Si le pides a Lovable o a Bolt que arregle este error, el cambio más rápido que lo hace desaparecer es uno de estos:
alter table orders disable row level security;
-- or
create policy "Allow all" on orders for all using (true) with check (true);
Los dos hacen que el error desaparezca. Los dos dejan también que cualquier
visitante con tu clave anon pública, que viaja en cada navegador que carga tu
app, lea y cambie todas las filas de la tabla. La app sigue funcionando y no
recibes ningún aviso; la fuga solo aparece cuando alguien se pone a buscar.
Si tu proyecto tiene una política con using (true) en una tabla con datos
de usuarios, trátalo como un bug aunque no falle nada. El checklist de
seguridad para apps hechas con
IA (en inglés) explica cómo
comprobarlo.
Ahora las causas reales, más o menos de más a menos frecuente en apps generadas con IA.
1. El insert no rellena user_id
La política típica dice que un usuario puede insertar una fila si la fila es suya:
create policy "Users insert own orders"
on orders for insert to authenticated
with check ((select auth.uid()) = user_id);
Si el frontend inserta { total: 40 } sin user_id, la columna queda a
null, auth.uid() = null no es verdadero y el insert se rechaza. Arréglalo
en la base de datos en lugar de confiar en que cada insert del frontend se
acuerde:
alter table orders alter column user_id set default auth.uid();
Ahora la columna se rellena sola a partir de la sesión y la política pasa.
2. No hay sesión en esa petición
auth.uid() lee el usuario del JWT que va con la petición. Si no hay JWT, la
petición se ejecuta con el rol anon y auth.uid() es null, así que todas
las políticas de "filas propias" fallan. Cómo pasa:
- El insert se ejecuta antes de que la sesión haya cargado, por ejemplo en un
componente que se dispara al montarse mientras
getSession()todavía se está resolviendo. - La política dice
to authenticatedpero la función está pensada para visitantes (un formulario de contacto, una lista de espera). Entonces necesitas una política de insert explícita y acotada paraanon, no la política de usuarios. - La escritura ocurre en una edge function que crea su propio cliente de
Supabase con la clave anon y no reenvía la cabecera
Authorizationde quien la llama.
Para ver qué rol usó una petición, abre la pestaña de red, busca la petición
que falla y mira su cabecera Authorization. Decodifica el token (jwt.io
sirve, pero no pegues tokens de producción en cualquier web; cualquier
decodificador local vale) y lee el claim role: anon o authenticated.
3. Profiles insertados desde el navegador justo después de signUp
Por esto "for table profiles" es la versión más común del error. La app llama
a supabase.auth.signUp() y justo después inserta una fila en profiles.
Con la confirmación por email activada, signUp no devuelve sesión hasta que
el usuario pulsa el enlace, así que el insert del perfil se ejecuta como
anon y falla. En la preview a menudo funcionaba porque la confirmación
estaba desactivada.
El patrón robusto, y el que documenta Supabase, es crear el perfil en la base
de datos con un trigger sobre auth.users, para que nunca dependa del
navegador:
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.profiles (id, full_name)
values (new.id, new.raw_user_meta_data ->> 'full_name');
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
Pasa los campos extra con options.data en signUp y léelos de
raw_user_meta_data como arriba. Luego borra el insert del lado del cliente.
Si el propio trigger falla, el registro falla con un mensaje "Database error
saving new user", así que pruébalo una vez con un registro real.
4. El insert pide la fila de vuelta y SELECT no está permitido
Con supabase-js, .insert(...).select() inserta la fila y luego la devuelve.
Devolver la fila necesita una política de SELECT además de la de INSERT. Si
solo escribiste la política de insert, la escritura se rechaza con el mismo
mensaje "new row violates", y eso manda a la gente a mirar la política
equivocada.
O añades una política de select para las filas que son del usuario, o quitas
.select() cuando no necesitas la fila de vuelta.
5. Upsert necesita tres políticas, no una
.upsert() es un insert que se convierte en update cuando la fila ya existe.
Necesita políticas de INSERT, UPDATE y SELECT que encajen las tres. El código
generado con IA usa upsert sin reparos en funciones tipo "guardar ajustes", y
las políticas normalmente solo cubren el insert. El error aparece la segunda
vez que el usuario guarda, y por eso parece aleatorio.
6. Subidas a Storage: la política está en storage.objects
En las subidas, la tabla del mensaje es objects, en el esquema storage.
Marcar un bucket como público solo hace que sus archivos se puedan leer por
URL. No permite que nadie suba nada. Las subidas necesitan una política de
INSERT en storage.objects, y sobrescribir un archivo existente
(upsert: true en las opciones de subida) necesita además SELECT y UPDATE.
El patrón habitual guarda los archivos de cada usuario en una carpeta con su ID:
create policy "Users upload to own folder"
on storage.objects for insert to authenticated
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
Para que esa política pase, la ruta de subida tiene que empezar por el ID del
usuario, por ejemplo USER_ID/avatar.png. Una subida a avatar.png en la
raíz del bucket falla, y también una ruta que use el ID del perfil cuando no
coincide con el ID del usuario de auth.
Cómo saber cuál es la tuya
Parte de la petición que falla, no de las políticas. En la pestaña de red
puedes ver la tabla, el método (POST para insert, PATCH para update, POST con
una cabecera Prefer: resolution=merge-duplicates para upsert), si la query
string pide la fila de vuelta (select= en la URL), el cuerpo (¿está
user_id?) y el rol del token. Con eso lo reduces a una de las seis
secciones de arriba en un par de minutos.
Después reprodúcelo en el SQL editor de Supabase como ese usuario, no como el superusuario postgres, que se salta RLS:
begin;
set local role authenticated;
set local request.jwt.claims = '{"sub": "PUT-THE-USER-UUID-HERE", "role": "authenticated"}';
insert into orders (total) values (40);
rollback;
Si falla, la política o los valores por defecto están mal. Si funciona, el problema es la petición que manda la app.
Código de servidor que tiene que escribir por cualquier usuario
Algunas escrituras no tienen usuario con sesión, y es lo correcto: un webhook
de Stripe que actualiza una suscripción, un cron, una acción de
administración. Esas se ejecutan en el servidor con la clave service_role,
que se salta RLS. Esa clave va solo en los secretos de la edge function o del
servidor. Si aparece en el código del frontend, o en cualquier variable con
prefijo VITE_ o NEXT_PUBLIC_, rótala hoy; cualquiera que haya cargado tu
app la tiene. Donde más lo vemos es en los handlers de pagos, y es una de las
causas que cubre los pagos de Stripe en Lovable no funcionan en
producción.
Cuando las políticas ya no admiten más parches
Un error de RLS es una política que corregir. Una app donde cada función nueva dispara otro, y donde la IA ha respondido a cada uno con una política más amplia, normalmente necesita reescribir las políticas tabla por tabla y probarlas con dos cuentas. Eso es una parte central del diagnóstico de nuestro rescate de apps hechas con IA: 450 €, 48 horas laborables, una lista por escrito de qué tablas están expuestas y qué políticas están mal, con el precio de cada arreglo, y los 450 € se descuentan si sigues con el rescate (entre 1.500 € y 6.000 €, con precio cerrado por alcance).
Si el error solo aparece en tu dominio real y nunca en la preview, revisa las otras diferencias entre los dos entornos en la guía para migrar una app de Lovable a producción; una sesión que no existe por culpa de una redirección del login rota es una causa de fondo frecuente, y está explicada en el login de Supabase redirige a localhost.