Supabase RLS a escala: 7 patrones para consultas que siguen rápidas
RLS ralentiza consultas de 2x a 11x en tablas grandes cuando las políticas omiten el wrapper SELECT, índices o esconden joins. 7 patrones útiles.
Row Level Security (RLS) es una función de Postgres que filtra filas según políticas adjuntas a las tablas. En Supabase es la forma estándar de imponer acceso por usuario y por tenant sin escribir un middleware en el servidor. La trampa es que una política se ejecuta en cada fila tocada por cada consulta, así que una política escrita sin pensar en rendimiento puede convertir una consulta de 5 ms en una de 500 ms. En producción vemos políticas RLS complejas ralentizar consultas de 2x a 11x en tablas grandes, mientras que las comprobaciones de propiedad simples se mantienen cerca del baseline solo cuando la columna subyacente está correctamente indexada.
Esta guía recoge 7 patrones que aplicamos en cada proyecto Supabase que ponemos en producción. Aplícalos en orden: cada uno se apoya en el anterior. Al final tendrás políticas que escalan a tablas multi-tenant con millones de filas sin quemar tu presupuesto de consultas.
Lo que necesitas antes de empezar
- Un proyecto Supabase con al menos una tabla que tenga RLS habilitado.
- Acceso al SQL editor en el dashboard de Supabase, o una conexión vía
psqlcon el service role. - Un esquema con al menos un límite de tenant (usuario, equipo u organización).
- Familiaridad con la salida de
EXPLAIN ANALYZE. La usamos para verificar que los cambios surtieron efecto.
1. Envuelve auth.uid() y las funciones helper en un SELECT
Por defecto Postgres reevalúa una función una vez por fila. Envolver la llamada en una subconsulta la asciende a InitPlan, así se ejecuta una vez por sentencia y el resultado se cachea. Supabase documenta este como el cambio de mayor impacto que puedes hacer en la mayoría de políticas.
-- Lento: auth.uid() corre en cada fila
create policy "owner can read"
on public.invoices for select
using (auth.uid() = user_id);
-- Rápido: auth.uid() corre una vez por sentencia
create policy "owner can read"
on public.invoices for select
using ((select auth.uid()) = user_id);Funciona con cualquier función cuyo valor de retorno no dependa de la fila evaluada. auth.uid(), auth.jwt() y tus propios helpers SECURITY DEFINER califican. Las funciones por-fila, como una que hashea una columna, no.
2. Indexa cada columna que la política lee
La política (select auth.uid()) = user_id solo se mantiene rápida si Postgres puede buscar binariamente en la columna user_id en lugar de escanear cada fila. En una tabla de un millón de filas, añadir un índice btree sobre user_id convierte un escaneo secuencial que toca cada fila en un lookup de índice que devuelve en milisegundos. Los benchmarks reales muestran más de 100x de mejora en tablas grandes una vez que la columna de la política está indexada.
create index invoices_user_id_idx
on public.invoices (user_id);Para tablas multi-tenant, añade un índice compuesto sobre las columnas que realmente filtras juntas: (tenant_id, status) supera a dos índices de columna única cuando ambas son parte de cada consulta. Para cargas parciales un índice parcial es aún mejor: create index ... where status = 'active' se mantiene pequeño y caliente en caché.
3. Añade un filtro explícito en el cliente aunque RLS lo haga
RLS añade una cláusula WHERE implícita, así que una consulta como .from('invoices').select() devolverá solo las filas que el usuario puede ver. El problema es que Postgres tiene menos hints de índice con los que trabajar cuando el filtro es solo de política. Un .eq('user_id', userId) explícito en el cliente permite al planificador usar el mismo índice en el que se apoya la política, en lugar de evaluar la política como una pasada final.
// Mal: RLS filtra pero el planificador no tiene hint
const { data } = await supabase.from('invoices').select();
// Bien: el filtro explícito refleja la condición RLS
const { data } = await supabase.from('invoices').select().eq('user_id', userId);Es una de esas reglas que la primera vez parece redundante. No lo es. Las pruebas internas de Supabase y varios reportes de producción muestran reducciones del tiempo de consulta entre 15% y 60% en cargas donde este es el único cambio aplicado.
4. Reemplaza los joins de política por subconsultas IN o ANY desde el lado del usuario
Una política multi-tenant ingenua hace join desde la tabla origen a la tabla de membresía:
-- Lento: join evaluado por fila
create policy "team members read"
on public.documents for select
using (
auth.uid() in (
select user_id from team_members
where team_members.team_id = documents.team_id
)
);Reescríbela para obtener los equipos del usuario una vez y usar IN contra el team_id de la tabla origen:
-- Rápido: la subconsulta resuelve una vez, se vuelve un lookup de set
create policy "team members read"
on public.documents for select
using (
team_id in (
select team_id from team_members
where user_id = (select auth.uid())
)
);La primera forma obliga a Postgres a consultar team_members por cada fila candidata. La segunda resuelve los equipos del usuario en un set pequeño, luego el planificador usa el índice sobre documents.team_id para filtrar directamente. En una tabla documents de 200.000 filas que respalda 500 equipos, hemos medido que esta reescritura sola baja la consulta de 380 ms a 22 ms.
5. Mueve los lookups costosos a funciones SECURITY DEFINER
Las políticas RLS se encadenan. Si tu política consulta otra tabla que tiene su propia RLS, cada chequeo de fila dispara otro set de políticas y el coste se multiplica. Las funciones SECURITY DEFINER corren como el rol que las definió (típicamente postgres), que puede tener BYPASSRLS. Una llamada a función desde dentro de una política cortocircuita la cascada.
create or replace function public.user_team_ids()
returns setof uuid
language sql
security definer
stable
set search_path = public
as $$
select team_id from team_members
where user_id = (select auth.uid());
$$;
create policy "team members read"
on public.documents for select
using (team_id in (select * from public.user_team_ids()));Este patrón también es la forma de salir de la recursión RLS: una política en la tabla A que necesita leer la tabla B que tiene su propia política que depende de A. Un helper SECURITY DEFINER rompe el bucle. Audita con cuidado estas funciones porque saltan el modelo de seguridad que estás tratando de imponer: restringe su search_path y nunca aceptes input no fiable.
6. Marca las funciones helper estables como STABLE
El hint de volatilidad STABLE le dice a Postgres que la función devuelve el mismo resultado para los mismos argumentos dentro de una sola sentencia. El planificador lo usa para cachear y reutilizar el resultado, y se combina con el patrón (select fn()) del paso 1. Una función marcada VOLATILE (el default) no se cachea ni siquiera envuelta.
create or replace function public.current_org_id()
returns uuid
language sql
stable -- ni volatile, ni immutable
security definer
as $$
select org_id from memberships
where user_id = (select auth.uid())
limit 1;
$$;Usa IMMUTABLE solo si el resultado depende realmente solo de los argumentos (una función hash, por ejemplo). Para cualquier cosa que lea de una tabla STABLE es el hint correcto y le da al planificador la caché que necesita.
7. Restringe las políticas con TO authenticated
Por defecto una política se aplica a cada rol, incluido anon. Aunque ningún usuario anónimo pudiera satisfacer la cláusula USING, Postgres evalúa la política para cada petición anónima. Añadir TO authenticated le dice al planificador que omita la evaluación entera para el rol anon.
create policy "owner can read"
on public.invoices for select
to authenticated
using ((select auth.uid()) = user_id);Acompáñala con una política separada para lecturas de service-role o admin si las necesitas. Dividir políticas por rol mantiene cada una barata.
Cómo verificar que funciona
Ejecuta EXPLAIN ANALYZE sobre una consulta representativa antes y después de cada cambio. Las métricas que importan:
- Nodo de plan: un
Seq Scantras el paso 2 significa que el índice falta o no se usa; quieres unIndex Scano unIndex Only Scan. - Presencia de InitPlan: tras el paso 1 deberías ver un
InitPlanparaauth.uid()o para tus funciones helper, no un Function Scan a nivel de fila. - Actual rows vs. Plan rows: un desfase de 100x significa que las estadísticas están desactualizadas; ejecuta
ANALYZEsobre la tabla. - Total execution time: al final es el único número que importa. Si no entra en el presupuesto de tu aplicación tras los 7 patrones, la política misma necesita rediseño.
Para ver el plan desde la API PostgREST que usa el cliente JS, habilita la feature explain en desarrollo con alter role authenticator set pgrst.db_plan_enabled to true; notify pgrst, 'reload config';, después llama .explain() sobre una consulta. Deshabilítala en producción.
Errores comunes y soluciones
La política es correcta pero cada consulta hace timeout
Verifica que la columna referenciada por la política esté indexada y que el índice incluya las columnas que el planificador necesita. Ejecuta EXPLAIN con BUFFERS on; un valor alto de Buffers: shared read apunta a un índice ausente. Si el índice existe pero no se usa, tus estadísticas están desactualizadas o el tipo de columna no coincide (una columna text comparada con un uuid vía cast implícito no usará el índice).
El patrón de función envuelta devuelve la fila equivocada
Vuelve a comprobar que la función no dependa de la fila. La optimización solo aplica cuando la función no lee la fila evaluada. Una función que toma la fila como input se sigue evaluando por fila.
Añadir TO authenticated devuelve cero filas
Probablemente tenías una consulta corriendo como service role o anon. Restringir a authenticated excluye ambos. Añade una política separada para el service role o usa el cliente service-role donde saltarse RLS sea intencional.
Una función SECURITY DEFINER filtra datos
Una función SECURITY DEFINER con BYPASSRLS devuelve lo que sea que su cuerpo seleccione, independientemente de quién la llamó. Filtra siempre dentro de la función sobre (select auth.uid()) u otro valor derivado del llamador, nunca sobre un argumento que el llamador pueda falsificar.
Para ir más lejos
RLS es una capa en una arquitectura Supabase multi-tenant. El conjunto de decisiones a su alrededor merece su propia lista de lectura. Cubrimos por qué cada SaaS debería nacer multi-tenant, donde RLS es el enfoque por defecto a nivel de esquema para el aislamiento de tenants. Para la capa de runtime que consume estas políticas, edge runtime vs Node runtime cubre el trade-off que la mayoría de despliegues Supabase en producción encuentran.
Studio
Empieza un proyecto.
Un partner único para empresas, sector público, startups y SaaS. Producción más rápida, tecnología moderna, costes reducidos. Un equipo, una factura.