Saltar al contenido

Fundamentos de seguridad

"Infinite recursion detected in policy" sin desactivar RLS

"Infinite recursion detected in policy for relation" significa que tu policy preguntó a la tabla que protege. Así se rompe el círculo.

Vlad Tkachenko11 min de lectura
Un panel de tabla cerrado con su propia llave dibujada dentro, detrás del cristal, y una barra cruzada sobre la puerta.

En resumen

  • "Infinite recursion detected in policy for relation" es el error 42P17 de Postgres. Una regla de Row Level Security sobre una tabla fue a hacerle una pregunta a esa misma tabla, y la pregunta no tiene final.
  • No es señal de que Row Level Security no sirviera para tu app. Es señal de que una regla preguntó en círculo.
  • Una función de ayuda pequeña lee la tabla fuera de las reglas y rompe el círculo. Desactivar Row Level Security en esa tabla también hace que el error pare, entregando cada fila que contiene a cualquiera que tenga la clave que viaja dentro de tu app.

Activaste Row Level Security, escribiste una regla para que los admins vean todos los perfiles y los demás vean solo el suyo, y ahora no carga ni una sola pantalla de tu app. Cada lectura vuelve con la misma línea:

infinite recursion detected in policy for relation "profiles"

La mayoría de las respuestas que vas a encontrar dicen lo mismo, y es lo equivocado: desactiva Row Level Security en esa tabla hasta que lo tengas resuelto. El error para, efectivamente. Lo que lo hace parar es que la tabla ahora la puede leer cualquiera que visite tu sitio.

Qué significa "infinite recursion detected in policy for relation"

Una regla sobre una tabla tuvo que leer esa misma tabla antes de poder responder.

Imagina un portero que comprueba a cada visitante contra una lista de invitados, y la lista está guardada dentro de la sala que vigila. Para leer la lista tiene que entrar. Para entrar tiene que comprobar la lista. No hay primer paso, así que se queda en el pasillo y nadie llega a ninguna parte.

Eso es lo que le pasó a tu base de datos. Le pidieron unas filas de profiles, fue a la regla de profiles para averiguar cuáles tenías permiso de ver, y la regla le dijo que mirara en profiles. Postgres nota que está dando vueltas, se rinde y lanza el error con el código 42P17 pegado. Supabase se lo pasa a tu app sin tocarlo, y por eso se lee como maquinaria.

La consulta falla para todos a quienes se aplica la regla, así que esto suele llegar como una app entera que se queda en blanco de golpe, y no como una sola pantalla rota.

El círculo, dibujado

La regla que lo provoca es la que casi todo el mundo escribe primero, porque dice exactamente lo que quieres decir:

-- Rechazar: esto lee profiles para decidir quién puede leer profiles.
create policy "admins read every profile" on profiles for select
to authenticated
using (
  exists (
    select 1 from profiles
    where id = (select auth.uid()) and role = 'admin'
  )
);

El exists (select 1 from profiles …) es todo el problema. Leer profiles significa aplicar la regla de profiles, que vuelve a ejecutar exists (select 1 from profiles …).

La petición no tiene nada de raro. El bucle está entre la regla y la tabla que la regla protege.

La propia guía de Row Level Security de Supabase nombra la versión de dos tablas: policies sobre dos tablas que leen cada una la otra nunca se resuelven, y la consulta falla para cada rol al que las policies se aplican. Una tabla leyéndose a sí misma es la misma forma sin el rodeo.

¿Por qué pasa siempre en la tabla profiles?

Porque en profiles está quién es esta persona.

Cualquier regla que trate a un tipo de persona distinto de otro tiene que averiguar de qué tipo es quien llama, y ese dato vive en una columna de alguna tabla. Se llame como se llame esa tabla, la regla que la protege acaba leyéndola. En una app hecha con Lovable, Bolt, v0 o Replit suele ser profiles, porque ahí es donde acaba la fila de cada persona con sesión iniciada. Una regla sobre equipos, organizaciones o documentos compartidos produce el mismo círculo en la tabla que guarde la pertenencia.

Así que la recursión sale de escribir la regla obvia sobre la única tabla que tiene que responder una pregunta sobre sí misma.

El arreglo: un ayudante que lee la tabla fuera de las reglas

Mueve la pregunta a una función pequeña que corre como el dueño de la base de datos, para que leer profiles y responderla no pase por la regla de profiles.

create schema if not exists private;

create function private.is_admin()
returns boolean
language sql
security definer      -- corre como el rol que la creó
set search_path = ''  -- así cada nombre de dentro hay que escribirlo completo
stable
as $$
  select exists (
    select 1 from public.profiles
    where id = (select auth.uid()) and role = 'admin'
  );
$$;

revoke execute on function private.is_admin() from public;
grant usage on schema private to authenticated;
grant execute on function private.is_admin() to authenticated;

-- La regla ahora pregunta a la función en vez de a la tabla.
create policy "admins read every profile" on profiles for select
to authenticated
using ( (select private.is_admin()) );

El portero tiene ahora la lista de invitados en una mesa del pasillo. La lee sin abrir la puerta, y la puerta sigue cerrada para todos los que no están en ella.

Cuatro líneas de ahí hacen un trabajo concreto, y tres de ellas son la diferencia entre un arreglo y un agujero nuevo:

  • security definer es lo que rompe el círculo. La guía de Supabase lo define como una función que corre bajo el mismo rol que creó la función, y en Supabase ese rol es postgres, que puede leer por encima de las reglas.
  • set search_path = '' le corresponde a todas y cada una. Supabase recomienda ponerlo en toda función security definer y escribir los nombres de tabla completos, porque sin un search_path fijado quien llama puede apuntar un nombre sin cualificar a un objeto suyo y hacerlo correr con los privilegios del dueño de la función. Por eso la función escribe public.profiles entero.
  • El esquema private importa por la misma razón. La guía de Supabase avisa de que una función security definer puesta en un esquema expuesto se puede llamar por la Data API con los privilegios de quien la creó, y te dice que nunca crees una en un esquema listado bajo Exposed schemas en los ajustes de tu API.
  • (select private.is_admin()), envuelto en un select propio. Eso deja que Postgres calcule la respuesta una vez para toda la sentencia en lugar de una vez por fila, que es lo que Supabase documenta como la razón de envolver auth.uid() igual.

Las líneas revoke y grant dejan la función invocable por los usuarios con sesión iniciada y por nadie más.

Esa policy cubre la lectura. Si guardar empieza a fallar en cuanto tus pantallas vuelven a llenarse, te has encontrado con otro mensaje con otra causa.

El arreglo que no arregla

Desactivar Row Level Security en profiles también hace que el error pare, en un clic, y entrega cada fila de esa tabla a cualquiera que tenga la clave que tu app envía al navegador.

Esa clave está pensada para ser pública. Está en el código de tu sitio, y cualquiera puede sacarla en unos segundos. Lo que hasta ahora le impedía devolver tu tabla de usuarios entera eran las reglas que acabas de desactivar.

Las dos hacen que el error pare. Solo una de ellas sigue respondiendo la pregunta que se le hizo a la regla.

Tu app tiene el mismo aspecto después en los dos casos, y esa es la parte que hace que esto se quede. Nada en tus pantallas dice cuál de las dos elegiste, y el error ha desaparecido en ambas.

Lo que sí lo dice es tu base de datos, preguntada desde fuera. Hemos escaneado 31.056 apps en producción hechas con Lovable, Bolt, v0, Replit y las demás, y vimos un proyecto de Supabase detrás de 8.435 de ellas. Esto es lo que encontró la comprobación de Row Level Security:

Lo que preguntamosApps
Tenían un proyecto de Supabase visible8.435
Se les pudo preguntar siquiera3.680
Devolvieron filas a una petición sin sesión2.096
De esas, desde una tabla de personas, pedidos o mensajes394

La fila de en medio es la honesta. En 4.755 de esas apps la comprobación no obtuvo respuesta, y lo decimos en vez de darlas por limpias. Y algunas de las 2.096 están pensadas para ser legibles, porque una tabla pública es algo que se puede querer de verdad; desde fuera, un menú y una lista de socios se parecen.

No podemos decirte cuántas de esas tablas las abrió alguien quitándose de encima este error exacto. Sí podemos decirte que una tabla abierta es el final normal del consejo que sale arriba del todo en los resultados de búsqueda.

Si prefieres no tener que pensarlo, nuestro escaneo gratuito lee tu sitio en producción y te dice cuáles de tus tablas le responden a un desconocido. Tarda unos 20 segundos y no pide cuenta: escanea tu app.

Pasa una cosa más cuando lo desactivas. El propio advisor de Supabase empieza a reportar la tabla, que es el aviso RLS disabled in public, y ese aviso seguirá ahí dentro de un mes.

Cómo saber si el círculo se ha ido de verdad

Que el error pare no es la prueba, porque hay dos cosas distintas que lo hacen parar.

Hay además una manera de que la función de ayuda parezca correcta y siga en recursión, y Supabase la documenta: una función security definer solo se salta las reglas si a su dueño le está permitido. En Supabase el dueño es postgres, que tiene ese privilegio, así que el patrón de arriba funciona. Una función que pertenezca a un rol sin él, o que lea una tabla puesta en force row level security, vuelve a pasar por la regla y se queda en el círculo.

Dos comprobaciones, y haz las dos:

Escribe los casos y ejecútalos. El procedimiento de Supabase es un archivo .sql bajo supabase/tests/ que afirma permiso y denegación para cada operación, para un usuario con sesión y para uno anónimo, lanzado con supabase test db. Un admin tiene que ver todos los perfiles, un miembro el suyo, un desconocido nada. Hasta que eso pase, lo que sabes es que el error paró.

Y después pregunta desde fuera, sin sesión. Esa es la pregunta que hace el navegador de un visitante, y la única que refleja lo que tu app reparte de verdad. Si un desconocido puede leer tu base de datos lo recorre paso a paso, y Row Level Security puede estar activado y la tabla seguir siendo pública cubre el caso en que las reglas existen y aun así dejan pasar a todo el mundo.

Cuando la recursión te dice que el esquema está mal

A veces no hay pregunta circular que quitar, porque las dos tablas dependen de verdad una de la otra.

La guía de Supabase usa compartir como ejemplo: una regla sobre lists consulta list_members para ver con quién se compartió la lista, y una regla sobre list_members consulta lists para ver de quién es. La regla de cada tabla lee la otra, y ninguna puede ir primero. El mismo ayudante lo deshace, con una función que devuelve los ids de las listas a las que pertenece quien llama, y las dos reglas preguntando a esa función en lugar de preguntarse entre ellas.

La señal a la que merece la pena atender es cuando necesitas tres o cuatro de estas para que un esquema funcione. A esas alturas la pertenencia se está reconstruyendo dentro de las reglas cada vez, y una sola tabla que diga claramente quién puede ver qué suele dar menos mantenimiento que las reglas que estás desenredando.

Cómo mantener la tabla cerrada después de arreglarla

Una regla que arreglaste hoy describe la base de datos de hoy. La siguiente policy, la siguiente tabla y la siguiente vez que alguien se quita un error de encima a medianoche pasan todas después de la última vez que miraste, y ninguna cambia nada que puedas ver en tus pantallas.

Reeve Monitor le vuelve a hacer a tu app las mismas preguntas, con un horario:

  • las nueve comprobaciones cada hora, en hasta tres apps
  • un mensaje cuando un resultado cambia, para que una tabla que se abrió anoche no espere a que te des cuenta
  • si la app responde, cada 60 segundos
  • un informe mensual de lo que vio

Si tu app guarda sus datos en tu propio proyecto de Supabase, Reeve Care además guarda una copia de esa base de datos. Una regla que deja leer a un desconocido es un problema. Una regla que lo deja escribir es el otro, y ninguna comprobación de este artículo devuelve filas borradas.

  • una copia cifrada de tu base de datos de Supabase cada noche, guardada donde tu proyecto no llega
  • cada copia verificada antes de contar, contando las filas de cada tabla
  • una restauración en un clic cuando la necesites
  • también tus archivos subidos, en cuanto conectes una credencial de Storage
  • todo lo que hace Monitor

Qué hacer ahora mismo

Qué hacer

  • Deja Row Level Security activado. El error va de una regla, y desactivar las reglas es un cambio en toda la tabla.
  • Mueve la comprobación del rol a una función security definer en un esquema no expuesto, con set search_path = '' y cada nombre de tabla escrito completo.
  • Apunta la policy a la función, envuelta como (select private.is_admin()), para que corra una vez por sentencia y no una vez por fila.
  • Escribe los casos permitidos y denegados bajo supabase/tests/ y ejecuta supabase test db: un admin ve todos los perfiles, un miembro el suyo, un desconocido nada.
  • Si ya desactivaste Row Level Security para poder avanzar, vuelve a activarlo hoy y arregla la regla como toca. El advisor de Supabase seguirá reportando esa tabla hasta que lo hagas, y le corresponde a todas las tablas que tengas.

Si quieres el resto de la lista para una app recién lanzada, la checklist de seguridad de 10 minutos cubre esto y las otras cosas que conviene cerrar antes de que alguien las encuentre.

Preguntas frecuentes

¿Qué provoca "infinite recursion detected in policy for relation"?

Una regla sobre una tabla que necesita leer esa misma tabla antes de poder responder. Tu regla viene a decir "deja pasar a esta persona si su fila en profiles dice que es admin", así que la base de datos va a leer esa fila en profiles, lo que obliga a comprobar la regla de profiles, que la manda otra vez a leer la fila. Postgres nota que está dando vueltas, se detiene y lanza el error 42P17. Lo mismo ocurre entre dos tablas cuyas reglas leen cada una la otra.

¿Es seguro usar una función security definer en una policy?

Sí, con dos condiciones que Supabase enuncia en su propia guía de Row Level Security. Pon search_path en la cadena vacía y escribe dentro cada nombre de tabla completo, es decir public.profiles y no profiles. Sin eso, alguien puede apuntar un nombre sin cualificar a un objeto suyo y hacerlo correr con los privilegios del dueño de la función. Y crea la función en un esquema que no esté expuesto en la API, porque una función security definer en un esquema expuesto se puede llamar desde fuera con los privilegios de quien la creó.

¿Debería desactivar RLS para arreglarlo?

Hace que el error pare y abre la tabla. Con Row Level Security desactivado, cualquiera que tenga la clave que tu app envía al navegador puede leer cada fila de esa tabla, y esa clave cualquiera puede sacarla de tu sitio. Tu app se comporta igual en los dos casos, así que después nada te dice cuál de las dos elegiste. La función de ayuda de más abajo hace que el error pare y deja la tabla cerrada.

¿Por qué pasa siempre en la tabla profiles?

Porque en profiles suele estar la respuesta a "quién es esta persona". Una regla que trata distinto a los admins, o distinto a los miembros de un equipo, necesita saber qué es quien llama, y ese dato está guardado en profiles. Así que la regla que protege profiles acaba leyendo profiles. Cualquier tabla que guarde el rol o la pertenencia de quien llama puede producirlo, y en una app hecha con Lovable, Bolt o v0 esa tabla suele ser la que se llama profiles.

¿Cómo compruebo que mi policy funciona de verdad?

Dos cosas distintas hacen que el error pare, así que por sí solo el error desapareciendo dice muy poco. Escribe los casos permitidos y denegados en un archivo .sql bajo supabase/tests/ y ejecuta supabase test db, que es el procedimiento que Supabase publica con la guía: un dueño con sesión iniciada tiene que pasar y un desconocido tiene que ser rechazado. Luego hazle a tu base de datos la misma pregunta desde fuera, sin ninguna sesión, como la haría el navegador de un visitante.

Escrito por

Vlad Tkachenko

Fundador de Reeve

Me paso el día mirando apps creadas con Lovable, Bolt, v0, Cursor y Replit, y la corta lista de errores que aparecen en ellas una y otra vez.

Más sobre el autor

Sigue leyendo

Todos los artículos

¿No sabes cómo está tu propia app?

Haz un escaneo gratuito y obtén una nota clara de la A a la F en unos 20 segundos. Sin cuenta y sin tarjeta.

Analizar mi app gratis

Comprobación externa automatizada, no una auditoría completa. La ausencia de hallazgos no es garantía de seguridad.