Saltar al contenido

Políticas RLS de Supabase: ejemplos probados con la API

Ver como MarkdownAbrir en ClaudeAbrir en ChatGPTAbrir en Perplexity

La pregunta también se copia. Si el chat se abre vacío, péguela.

En resumen

Aquí están las políticas RLS que necesitan la mayoría de las apps con Supabase, cada una probada a través de la API REST como invitado, como dueño de la fila y como otro usuario autenticado. Hay tres resultados que conviene conocer antes de copiar nada: un update sobre la fila de otro devuelve 200 con una lista vacía, no un error; una política de insert de apariencia inofensiva en una tabla de membresías dejó que un usuario se uniera a otra empresa y leyera sus proyectos; y escribir (select auth.uid()) en lugar de auth.uid() bajó una consulta de 156 ms a 7,4 ms con 200.000 filas.

Soy Denys, ingeniero full-stack y de IA, y la mayoría de las apps que construyo o reparo funcionan sobre Supabase. Los fragmentos de políticas están por todas partes; lo que hacen cuando les llega una petición real casi nunca se muestra. Así que los ejecuté. Estas son las políticas RLS de Supabase con las que suelo empezar, con el resultado de cada una.

Cómo probé. La mayoría de las peticiones pasaron por un stack local de Supabase (CLI 2.109.1, Postgres 17.6, PostgREST 14.14, Auth 2.192.0) con la clave publicable y dos usuarios reales autenticados, A y B. Los casos que no necesitan HTTP, como los errores de sintaxis y las mediciones de tiempo, se ejecutaron en un Postgres 17.10 local normal, con los roles anon y authenticated de Supabase y auth.uid() recreado. Todo el 24 de octubre de 2026, con filas inventadas.

Antes de cualquier política: ¿la tabla tiene un grant?

Una política decide qué filas ve un rol. Un grant decide si el rol llega siquiera a la tabla, y en los proyectos actuales de Supabase las tablas nuevas ya no reciben grants de la Data API automáticamente (a partir del 30 de octubre de 2026 en todos los proyectos). En mi stack, una tabla con RLS activado y sin grant respondió esto:

Petición Estado Cuerpo
Invitado GET /invoices 401 permission denied for table invoices, con la pista GRANT SELECT ... TO anon
Usuario A GET /invoices 403 lo mismo, con la pista para authenticated

Así que el primer paso para cada tabla que la app lee desde el navegador es:

alter table public.todos enable row level security;
grant select, insert, update, delete on public.todos to authenticated;

Conceda solo los comandos que el navegador necesita, y solo a authenticated, salvo que los invitados deban leerla.

¿Cómo hacer que los usuarios vean solo sus propias filas?

Una política por comando, cada una limitada a usuarios autenticados:

create policy "read own" on public.todos
  for select to authenticated using ((select auth.uid()) = user_id);
create policy "add own" on public.todos
  for insert to authenticated with check ((select auth.uid()) = user_id);
create policy "edit own" on public.todos
  for update to authenticated
  using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id);
create policy "delete own" on public.todos
  for delete to authenticated using ((select auth.uid()) = user_id);

A tiene dos filas, B tiene una. Esto devolvió cada petición:

Petición de Qué Estado Cuerpo
Invitado leer todo 401 permission denied (sin grant para anon)
A leer todo 200 las 2 filas de A
A insertar una fila de B 403 new row violates row-level security policy
A update de la fila de B 200 []
A delete de la fila de B 200 []
A pasar su fila a B 403 new row violates row-level security policy

Las dos líneas 200 [] son las que hay que recordar. RLS no rechaza un update o un delete de filas que no puede ver: las filtra, y cero filas cambiadas cuenta como éxito. Si su app trata “sin error” como “guardado”, le dirá al usuario que su edición funcionó cuando no pasó nada. Pida las filas modificadas (.select() en supabase-js, Prefer: return=representation por HTTP) y compruebe que volvió una.

¿Por qué una política de update necesita WITH CHECK?

using elige qué filas existentes puede tocar un usuario; with check decide cómo pueden quedar esas filas después. Comparé tres versiones de la misma política de update:

Política de update A le da su fila a B
using (uid = user_id) with check (uid = user_id) 403, rechazado
using (uid = user_id), sin with check rechazado (Postgres reutiliza using como check)
using (uid = user_id) with check (true) 200, la fila ahora es de B

Omitir with check es más seguro de lo que parece, porque Postgres recurre a la expresión de using. Lo que deja a un usuario mover una fila a la cuenta de otro es with check (true), que algunos generadores escriben para “permitir updates”. Escriba el check completo.

¿Cómo hacer públicos los perfiles pero editables solo por su dueño?

create policy "public read" on public.profiles
  for select to anon, authenticated using (true);
create policy "own edit" on public.profiles
  for update to authenticated
  using ((select auth.uid()) = id) with check ((select auth.uid()) = id);

En la prueba con Postgres normal: el invitado leyó los dos perfiles, el update del invitado cambió 0 filas, y el update profiles set display_name = 'x' de A, sin filtro, cambió exactamente 1 fila, la propia de A. using (true) está bien para un select sobre datos que deben ser públicos. En cualquier otra tabla equivale a no tener RLS, y una política escrita sin cláusula to también se aplica a los invitados: mi invitado leyó la fila “privada” a través de una política using (true) sin to.

¿Cómo compartir filas dentro de un equipo?

La forma habitual: una tabla memberships y una política sobre los datos que busca allí los equipos del usuario.

create policy "members read" on public.projects
  for select to authenticated
  using (org_id in (select org_id from public.memberships
                    where user_id = (select auth.uid())));

Esa política está bien. La fuga estaba al lado. A memberships le puse la política que sugieren muchos tutoriales, “los usuarios pueden insertar su propia membresía”:

create policy "join any org" on public.memberships
  for insert to authenticated with check (user_id = (select auth.uid()));

Comprueba para quién es la fila, no a qué equipo. A, miembro de la empresa 1, envió una petición:

Una política de insert en memberships. Usuario A de la empresa 1, por la API REST. Política: user_id = auth.uid(): Usuario A → se une a empresa 2 → 201 Created → lee nómina de B; Sin política de insert: Usuario A → se une a empresa 2 → rechazado → solo sus proyectos.
Figura 1. Mi prueba en un stack local de Supabase (CLI 2.109.1), 24 de octubre de 2026.
Petición de A Estado Cuerpo
leer proyectos 200 A roadmap
insertar membresía {user_id: A, org_id: 2} 201 creada
leer proyectos otra vez 200 A roadmap, B payroll

La solución es no darle a memberships ninguna política de insert, de modo que el navegador no pueda escribir en ella (en la prueba con Postgres, el mismo insert se rechazó entonces con 42501), y añadir miembros mediante un flujo de invitación que se ejecute en el servidor o en una función que compruebe la invitación. Un usuario que pertenece a dos empresas es la siguiente trampa; la expliqué en por qué un usuario en dos orgs filtra datos.

¿Y si una política necesita leer su propia tabla?

“Los miembros pueden ver a sus compañeros” significa una política sobre memberships que lee memberships. Postgres la rechaza al ejecutar la consulta:

ERROR 42P17: infinite recursion detected in policy for relation "members2"

Mueva la búsqueda a una función que se ejecute como su propietario, en un esquema que la API no expone, con un search_path fijo:

create schema private;
create function private.user_org_ids() returns setof int
  language sql stable security definer set search_path = ''
  as $$ select org_id from public.members2 where user_id = (select auth.uid()) $$;
revoke all on function private.user_org_ids() from public;
grant usage on schema private to authenticated;
grant execute on function private.user_org_ids() to authenticated;

create policy "see teammates" on public.members2
  for select to authenticated using (org_id in (select private.user_org_ids()));

Después, A vio a los dos miembros de la empresa 1 y B se vio solo a sí mismo. Mantenga la función fuera de public: todo lo que esté allí se puede llamar a través de la API.

¿Cómo exigir MFA para filas sensibles?

Añada una política restrictiva encima de la normal. Las políticas restrictivas se combinan con and, así que ambas deben cumplirse:

create policy "mfa only" on public.payouts as restrictive
  for select to authenticated using ((select auth.jwt() ->> 'aal') = 'aal2');

Con el mismo usuario, una sesión en aal1 leyó 0 pagos y una sesión en aal2 leyó 1.

¿Qué tablas deben tener RLS activado y ninguna política?

Las tablas que solo toca su servidor. RLS activado sin política le niega todo a los roles del navegador: en la prueba con Postgres, el invitado y A leyeron 0 filas, y el insert de A se rechazó con 42501. Dejar RLS desactivado es lo contrario. Una tabla con RLS desactivado y grants por defecto, leída con la clave publicable y sin iniciar sesión:

Petición del invitado Estado Cuerpo
GET /leads 200 las dos filas
DELETE /leads?id=gt.0 200 las dos filas, borradas

De eso trata el aviso del Advisor “RLS disabled in public”.

¿Puede una política cubrir varios comandos?

No con una lista. for update, delete es un error de sintaxis (42601: syntax error at or near ","), aunque sigue apareciendo en colecciones de fragmentos publicadas. for all es válido y aplica un único using y un único with check a todos los comandos, que rara vez es lo que se quiere. Una política de insert con using también falla: only WITH CHECK expression allowed for INSERT.

¿Las políticas ralentizan las consultas?

Pueden hacerlo. Una expresión de política se ejecuta por cada fila que Postgres considera. Medí select * from big como usuario A con 200.000 filas de 1.000 usuarios, de las cuales A tiene 200 (mediana de 7 ejecuciones, Postgres 17.10):

Una política solo del dueño, cuatro formas. Milisegundos para leer 200 filas propias de 200.000. Mediana de 7 ejecuciones. auth.uid() = user_id, sin índice: 156,30; (select auth.uid()), sin índice: 7,40; auth.uid() = user_id, con índice: 0,21; (select auth.uid()), con índice: 0,05.
Figura 2. Mi prueba en un Postgres 17.10 local con los roles de Supabase recreados, 24 de octubre de 2026.
Política e índice Mediana
auth.uid() = user_id, sin índice 156,3 ms
(select auth.uid()) = user_id, sin índice 7,4 ms
auth.uid() = user_id, índice en user_id 0,21 ms
(select auth.uid()) = user_id, índice 0,05 ms

Envolver la llamada en select permite que Postgres la calcule una vez por consulta en lugar de una vez por fila; el índice hace el resto. La guía de rendimiento de RLS de Supabase informa de un cambio del mismo orden en su propia tabla de 100.000 filas (de 179 ms a 9 ms). Muchas entradas de blog citan esas cifras como propias; las de arriba son de mi ejecución.

¿De dónde deben salir los roles?

De app_metadata o de su propia tabla, nunca de user_metadata. Un usuario autenticado puede cambiar su propio user_metadata con supabase.auth.updateUser(), así que una política que comprueba auth.jwt() -> 'user_metadata' ->> 'role' le permite concederse el rol a sí mismo. Un trigger de registro que copia un rol de los metadatos del registro hace lo mismo; ese caso está en cómo un registro fue Owner.

¿Cómo comprobar sus políticas RLS de Supabase?

  • El verificador de seguridad de Supabase, gratuito, lee su proyecto en modo de solo lectura y señala las políticas siempre verdaderas, las políticas de escritura para invitados, las políticas que confían en los metadatos del usuario y las tablas con RLS desactivado, con el SQL de corrección para cada una.
  • Ningún escáner sabe qué filas pertenecen a quién. Para eso, ejecute peticiones como dos usuarios, como hace este artículo; el método completo para una app con roles está en cómo probar Supabase RLS.
  • Qué asegura Supabase por usted y qué le deja a usted está en ¿es seguro Supabase?.
  • Las mismas pruebas de dueño y de equipo, ejecutadas también en Appwrite, están en Appwrite vs Supabase: los mismos ataques en ambos.

Límites. Un stack local y un portátil, datos inventados, 24 de octubre de 2026. Los tiempos dependen del hardware y de la forma de la tabla; importan más las proporciones que los milisegundos. Las políticas de Storage y Realtime no se tratan aquí.

¿No está seguro de que sus políticas aguanten?

Me gustaría revisar un esquema con usted. Sin pitch.

Escríbame

© 2026 Denys Zaichenko. Artículo original: https://madsalt.tech/es/blog/supabase-rls-policy-examples/