BusiRocket

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 authenticated pero 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 para anon, 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 Authorization de 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.

Siguiente paso

¿Te suena este problema en tu proyecto?

Cuéntanoslo y te decimos cómo lo abordaríamos, en qué orden y con qué coste.