Todos los recursos

Supabase te entrega dos claves con radios de impacto muy distintos, y casi todas las fugas salen de poner la equivocada en el navegador. Este es el recorrido completo que sigo en cada proyecto: elige la clave correcta para cada superficie, activa Row Level Security, escribe políticas que aguanten un ataque y comprueba que el camino de negación de verdad niega, con los comandos y los tropiezos que le cuestan caro a la gente en producción.

Claves de API con alcance y RLS en Supabase: deja que la base de datos decida quién lee qué

En resumen

  • Dos claves, dos mundos: la clave publishable (anon) es segura en el navegador y vive sujeta a RLS; la clave secret (service-role) se salta toda política y equivale a tener la base de datos en la mano.
  • RLS se activa tabla por tabla. Una tabla sin RLS está abierta de par en par para la clave anon; una con RLS y cero políticas lo niega todo, y las dos parecen bugs.
  • Las políticas corren con la identidad de quien hace el request. auth.uid() dentro de una política es el id del usuario logueado, y es la base la que decide fila por fila, no el código de tu app.
  • La clave service-role ignora RLS por completo. Cualquier ruta de servidor que la use es tan confiable como la propia base de datos, así que trátala igual de en serio.
  • No has terminado una política hasta que la pruebas haciéndote pasar por otra persona. Prueba como anon, como dueño y como un usuario distinto, y confirma que el camino de negación niega.

Supabase hace peligrosamente fácil sacar un backend, y peligrosamente fácil sacar uno que filtra datos. Las claves por defecto, la API REST autogenerada, esa librería de cliente tan amable: todo funciona desde el primer día, también para el atacante que lee las filas que olvidaste proteger. Esta guía te lleva por la disciplina que evita que eso pase: qué clave va en cada superficie, cómo Row Level Security decide de verdad quién ve una fila, y cómo comprobar que tus políticas aguantan antes de que lo haga un extraño por ti. Te vas con una tabla de notas funcionando, SQL real y una prueba que puedes correr contra el camino de negación.

Trabajo con este stack todos los días en Ilustrari (que opera como NexoString): sistemas multiagente y de apoyo a la decisión sobre Anthropic Claude, TypeScript, Next.js y Supabase, más Infuse, nuestro gestor de secretos de API. Lo que viene aquí no es teoría; es justo lo que separa a un fundador solo de un reporte de incidente.

Importante

Requisitos previos: un proyecto de Supabase, el CLI de Supabase instalado y enlazado (supabase link), Postgres al nivel de "sé leer un CREATE POLICY" y una app que autentique usuarios con Supabase Auth para que auth.uid() tenga valor. Si tus usuarios no entran por Supabase, RLS igual funciona, pero estarías escribiendo políticas contra tus propios claims, y eso da para otro artículo.

01 · Dos claves, dos radios de impacto

Todo proyecto de Supabase trae dos claves que parecen intercambiables y que no lo son ni de cerca.

  • La clave publishable (también la verás como la clave anon). Está pensada para vivir en el navegador. No carga privilegios propios. Todo lo que puede hacer es lo que RLS le permita. Si se te filtra, filtraste lo que de todos modos ibas a publicar. Es la que va en variables NEXT_PUBLIC_.
  • La clave secret (la clave service-role). Esta se salta RLS por completo. Cualquier política que escribas le resulta invisible. Puede leer, escribir y borrar cualquier fila de cualquier tabla. Va en el servidor: route handlers, edge functions, jobs en segundo plano, y en ningún otro lado.

La falla de seguridad más común en Supabase es poner la clave secret donde el navegador la ve. Pasa porque las dos claves se parecen, están una al lado de la otra en el dashboard y la librería de cliente acepta cualquiera de las dos sin chistar. La librería no te va a frenar cuando le pongas una clave de permiso total en las manos a cada visitante.

Atención

Nunca pongas la clave service-role en una variable NEXT_PUBLIC_, en un client component ni en nada que se empaquete para el navegador. Todo lo que lleva ese prefijo en Next.js se manda al cliente en texto plano. Si la clave secret tocó alguna vez una variable NEXT_PUBLIC_, rótala ya. Quedó comprometida en el momento del build, no cuando alguien se da cuenta.

Un modelo mental que te evita el error

Lee la clave anon como "un request de alguien en quien todavía no confío, ya la base decidirá qué le toca." Lee la clave service-role como "este código ES la base de datos." Cuando piensas la clave service-role como la base misma, dejas de regarla por helpers de conveniencia, porque tampoco le expondrías las credenciales crudas de la base a un frontend.

02 · Activa RLS, tabla por tabla

Row Level Security es el mecanismo que hace seguro mandar la clave anon al navegador. Es una función de Postgres: reglas por tabla que deciden, fila por fila, si quien hace el request puede hacer select, insert, update o delete.

La trampa que agarra a todo el mundo: RLS se activa tabla por tabla. Una tabla que creaste sin activarlo queda totalmente legible y escribible por la clave anon. No hay un interruptor global. Cada tabla con datos de usuario necesita que actives RLS a mano y de forma explícita.

Armemos un ejemplo realista, una tabla notes donde cada usuario solo debería ver sus propias notas.

create table notes (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users (id) default auth.uid(),
  title text not null,
  body text,
  created_at timestamptz not null default now()
);

-- Todavía nada protege esta tabla. Activa RLS.
alter table notes enable row level security;

-- Con RLS activo y cero políticas, se niega TODO.
-- Agrega el camino de lectura: un usuario lee sus propias filas.
create policy "el dueño lee sus notas"
on notes for select
to authenticated
using (auth.uid() = user_id);

Tres cosas en ese snippet hacen el trabajo de verdad:

  1. El default en user_id. Ponerlo en auth.uid() hace que cada insert estampe al dueño solo, así que el cliente nunca llega a elegir de quién es la nota.
  2. to authenticated. Esta política aplica a usuarios logueados. El rol anónimo no recibe nada por aquí, que suele ser lo que quieres para datos privados.
  3. using (auth.uid() = user_id). Este es el filtro que la base aplica a cada fila candidata. auth.uid() se resuelve al id del usuario en el JWT, evaluado en el servidor, sin confiar en el cliente.

Nota

Activar RLS sin políticas niega todo acceso, y lo hace calladito. Tus lecturas devuelven arreglos vacíos, no errores, así que parece "no hay datos" cuando en realidad es "no hay permiso." Agrega siempre la política SELECT en la misma migración que activa RLS, o te vas a pasar una hora depurando una pared que levantaste tú mismo.

03 · Escribe políticas que aguanten un ataque

Una política SELECT es el comienzo, no el final. Lecturas, inserts y updates son permisos separados, y los writes necesitan una segunda cláusula que casi todo el mundo olvida.

USING vs WITH CHECK

  • USING filtra qué filas existentes puede tocar una sentencia. Manda en SELECT, UPDATE y DELETE: "qué filas tienes permiso de ver o de tocar."
  • WITH CHECK valida cómo queda la fila nueva en INSERT y UPDATE: "tienes permiso de dejarla así." Sin esto, un usuario pasa RLS sobre las filas que sí son suyas y después hace un update para poner user_id en otra persona, robando o plantando datos sin que nadie se entere.

Este es el set completo para la tabla notes, la versión que yo de verdad desplegaría:

create policy "el dueño inserta sus notas"
on notes for insert
to authenticated
with check (auth.uid() = user_id);

create policy "el dueño actualiza sus notas"
on notes for update
to authenticated
using (auth.uid() = user_id)        -- solo puedes apuntar a filas tuyas
with check (auth.uid() = user_id);  -- no puedes ceder la propiedad a otro

create policy "el dueño borra sus notas"
on notes for delete
to authenticated
using (auth.uid() = user_id);

Fíjate que no hay ninguna política que conceda lecturas amplias, ni una para el rol anónimo, ni nada destructivo que el cliente pueda alcanzar más allá de sus propias filas. La idea es denegar por defecto: lo que no se permite de forma explícita, se niega, y la operación peligrosa no se niega con un flag, sencillamente no existe.

Mantén los predicados de la política baratos e indexados

Las políticas corren en cada fila que mira la consulta, así que un predicado caro es un impuesto sobre cada request. Dos reglas:

  • Compara contra una columna indexada. user_id debería tener un índice; un filtro de RLS sobre una columna sin índice convierte cada lectura en un sequential scan.
  • Envuelve auth.uid() en un select, como (select auth.uid()), para que Postgres lo evalúe una vez por sentencia y no una vez por fila. En tablas grandes esa es la diferencia entre una consulta ágil y un timeout.

Consejo

Si una política necesita más que una comparación simple de columna, por ejemplo "los miembros del mismo equipo pueden leer," saca esa lógica a una función security definer y llámala desde la política. Así la política queda legible, puedes reusar la regla entre tablas y evitas que RLS entre en recursión sobre la misma tabla que está protegiendo.

04 · La clave service-role: mucho privilegio, huella acotada, auditada

A veces de verdad necesitas saltarte RLS: un handler de webhook que escribe en nombre del sistema, un job programado, un export de admin. Para eso está la clave service-role. La disciplina está en dejar su huella lo más pequeña posible.

import { createClient } from "@supabase/supabase-js"

// SOLO EN EL SERVIDOR. Léela de una env var que no sea pública.
// Este cliente ignora cada política RLS que escribiste.
const admin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,  // nunca NEXT_PUBLIC_
  { auth: { persistSession: false, autoRefreshToken: false } }
)

Trata cada ruta que importe este cliente como parte de tu núcleo de confianza:

  1. Aíslalo. No riegues el cliente service-role por todo el código. Un módulo lo crea, un puñado de funciones bien revisadas lo usan y todo lo demás pasa por el cliente anon, donde RLS te protege por defecto.
  2. Re-verifica la autorización tú mismo. Como RLS está apagado para esta clave, la base no te va a frenar un bug de lógica. Si una ruta service-role actúa en nombre de un usuario, verifica en código la identidad y los permisos de ese usuario antes de tocar una sola fila.
  3. Guárdala en un gestor de secretos de verdad. Una clave service-role en un .env subido al repo, en un log de CI o en un DM de Slack es una brecha esperando a que alguien se dé cuenta. Justo para esto existe Infuse: traer la clave como toca cuesta menos esfuerzo que pegarla donde no va, así que nadie termina saltándose el camino correcto.

Atención

No recurras a la clave service-role solo para que una consulta "funcione." Nueve de cada diez veces la lectura falló porque falta una política, no porque necesites saltarte las políticas. Saltarte RLS para tapar un hueco de RLS convierte un problema de configuración en una puerta trasera permanente.

05 · Ejemplo práctico: comprueba que el camino de negación niega

Una política que no atacaste es una política que no probaste. El error es probar solo como tú mismo. Claro que puedes leer tus propias notas. La pregunta es si puede otra persona.

Corre esto en el editor SQL o en una sesión del CLI. Te haces pasar por los roles igual que lo haría un request entrante, así pruebas el camino de enforcement real y no el camino feliz de tu app.

-- Siembra notas de dos usuarios (como service-role / editor SQL, RLS saltado).
insert into notes (user_id, title) values
  ('11111111-1111-1111-1111-111111111111', 'nota de ana'),
  ('22222222-2222-2222-2222-222222222222', 'nota de beto');

-- Ahora actúa como Ana: pon el rol authenticated y sus claims del JWT.
set local role authenticated;
set local request.jwt.claims = '{"sub":"11111111-1111-1111-1111-111111111111"}';

select title from notes;          -- esperado: solo 'nota de ana'

-- Intenta leer la fila de Beto explícitamente. RLS debe seguir ocultándola.
select title from notes
where user_id = '22222222-2222-2222-2222-222222222222';  -- esperado: 0 filas

-- Intenta robar una fila reasignando la propiedad.
update notes set user_id = '11111111-1111-1111-1111-111111111111'
where title = 'nota de beto';     -- esperado: 0 filas afectadas

Si Ana ve solo su nota, le salen cero filas por la de Beto y su update de robo no afecta nada, tus políticas aguantan. Si alguno devuelve el resultado equivocado, ahí tienes un hallazgo real. Arréglalo antes de entregar, no después de que lo encuentre un cliente. Repite la prueba de lectura con el rol anon y sin JWT para confirmar que los visitantes deslogueados no reciben nada.

Tropiezos comunes que salen caros en producción

  • "En local funciona pero en prod no devuelve nada." En local usas la clave service-role (RLS saltado) y en prod la clave anon (RLS aplicado), y nunca escribiste la política SELECT. El arreglo es la política, no la clave.
  • El WITH CHECK olvidado. Las lecturas están cerradas, pero un usuario igual puede hacer INSERT de filas de otra persona, o UPDATE para cambiar la propiedad. Empareja siempre las políticas de write con WITH CHECK.
  • RLS activo, políticas en el rol equivocado. Una política con alcance to authenticated no hace nada por los visitantes anónimos, y una política sin rol aplica a todos los roles, incluidos los que no querías tocar. Sé explícito con el rol.
  • Confiar en que el cliente ponga user_id. Si el cliente puede mandar user_id, puede mentir sobre él. Ponlo por default en auth.uid() y exige la igualdad en WITH CHECK, para que el valor lo decida la base y nunca el request.
  • Vistas que se saltan RLS en silencio. Una vista creada por un dueño con privilegios puede devolver filas que quien la llama no debería ver. Marca las vistas con security_invoker para que corran con los permisos de quien llama.

Acierta en la frontera de las claves y deja que RLS cargue con el resto: tu autorización deja de vivir regada en chequeos de aplicación y pasa a vivir en un solo lugar que la base impone por ti. Prueba como anon, como dueño y como un extraño cada vez que toques una política, porque el camino de negación es la única parte del sistema que de verdad le importa a un atacante. Toda la disciplina cabe en una frase: la clave anon es un request en el que aún no confías, la clave service-role es la base de datos, y RLS es la línea entre las dos.

Puntos clave

  • La clave anon es segura en el navegador y vive sujeta a RLS; la clave service-role es la base misma. Mantenla en el servidor y nunca en NEXT_PUBLIC_.
  • RLS se activa tabla por tabla. Actívalo en cada tabla con datos de usuario y agrega la política SELECT en la misma migración para no negarlo todo por accidente.
  • Empareja cada política de write con WITH CHECK para que nadie pueda reasignar la propiedad ni plantar filas bajo el id de otro.
  • Indexa las columnas de tus políticas y envuelve auth.uid() en un subselect para que RLS siga saliendo barato a escala.
  • No has terminado una política hasta probar el camino de negación: como anon, como dueño y como un extraño.

Preguntas frecuentes

¿De verdad es seguro exponer la clave publishable (anon) en el navegador?

Sí, justo para eso es, siempre y cuando RLS esté activo en cada tabla con datos privados. La clave anon no tiene privilegios propios; solo puede hacer lo que tus políticas permitan. El peligro no es que la clave sea pública, es esa tabla donde olvidaste activar RLS, porque ahí la clave pública lee sin freno. Audita tus tablas, no la visibilidad de la clave.

Activé RLS y ahora mis consultas me devuelven arreglos vacíos. ¿Rompí algo?

Activaste RLS sin escribir una política SELECT, así que la base lo está negando todo, bien hecho pero calladita. RLS con cero políticas niega todo, y Postgres devuelve resultados vacíos en vez de un error, lo que se lee como 'no hay datos.' Agrega una política SELECT (normalmente con alcance to authenticated, filtrando por auth.uid()) y las lecturas vuelven. Lo recomendable es escribir la política en la misma migración que activa RLS.

¿Cuándo está bien de verdad usar la clave service-role?

Cuando el código del servidor de verdad necesita actuar por fuera de los permisos de cualquier usuario en particular: handlers de webhook, jobs programados, herramientas de admin, writes a nivel de sistema. Nunca en el navegador, y nunca como atajo porque una consulta normal 'no funciona,' lo que casi siempre significa que falta una política, no que necesites saltarte RLS. Cuando sí la uses, aíslala en un módulo, re-verifica la autorización en código y guárdala en un gestor de secretos.

¿De verdad necesito WITH CHECK si ya tengo una cláusula USING?

Para los writes, sí. USING decide a qué filas existentes puedes apuntar; WITH CHECK valida cómo queda la fila nueva después de un INSERT o UPDATE. Sin WITH CHECK en una política de UPDATE, un usuario puede agarrar una fila que sí es suya y reasignarle el user_id a otra persona, o insertar filas bajo el id de otro. Las dos cláusulas cuidan momentos distintos. Sáltate WITH CHECK y dejas un hueco silencioso de robo de propiedad.

¿Cómo pruebo las políticas sin tener que levantar toda la app?

Hazte pasar por los roles directo en SQL. Usa SET LOCAL ROLE authenticated y SET LOCAL request.jwt.claims con el sub del id del usuario por el que te quieres hacer pasar, y corre tus consultas. Ese es el mismo camino de enforcement que toma un request real. Prueba como dueño (debe ver sus filas), como otro usuario (debe ver cero filas del primero) y como anon (no debe ver nada privado). La prueba que importa es la que debería fallar.

¿RLS me hace más lentas las consultas?

Puede pasar, si te descuidas. El predicado de una política corre contra las filas que mira la consulta, así que compara sobre una columna indexada (indexa tu user_id) y envuelve auth.uid() en un subselect, como (select auth.uid()), para que se evalúe una vez por sentencia y no una vez por fila. Con esos dos hábitos, el overhead es despreciable en la mayoría de las cargas. Lo que de verdad mata el rendimiento es un filtro de RLS sobre una columna sin índice que termina forzando un sequential scan.

¿Prefieres que lo hagamos por ti?

Esto mismo lo construimos para negocios como el tuyo. La primera conversación es gratis y sin compromiso.

Escríbenos por WhatsApp

Escríbenos por WhatsApp

Escanéalo con tu teléfono para escribirnos por WhatsApp.

Escanéalo con tu teléfono para escribirnos por WhatsApp.

¿Estás desde el teléfono y no puedes escanear? Escríbenos a info@ilustrari.com

Primera conversación gratis. Te responde el fundador.

Recursos relacionados

SkillSupabase

El skill que escribe políticas RLS: políticas de Postgres que niegan por defecto y se prueban con casos de permitir y denegar

Un skill de Claude Code que convierte 'esta tabla guarda datos por usuario' en políticas de Row-Level Security que niegan por defecto, se escriben en la misma migración que activa RLS y solo se entregan con tests de permitir y denegar que prueban que de verdad funcionan.

23 may 202612 min de lectura
PromptSupabase

Redactor de políticas RLS: un prompt que escribe políticas de Supabase y luego las ataca

Row Level Security es donde las apps de Supabase se filtran sin que nadie se dé cuenta: una política que se lee bien se olvida del INSERT, confía en una columna que pone el cliente, o cuida el USING pero no el WITH CHECK. Este es el prompt, listo para copiar y pegar, que usamos para redactar el set completo de políticas de una tabla, una política por operación, y después hacer que el modelo le haga red-team a su propio trabajo en una sección de NOTAS DE AMENAZA. Te llevas el template, las variables que tienes que rellenar, variantes para multi-tenant y storage, y los detalles que convierten una política de aspecto impecable en una lectura cruzada entre tenants.

12 abr 202612 min de lectura
GuíaClaude

Un modelo de amenazas para agentes que usan herramientas

Un agente que solo conversa es de bajo riesgo. Un agente que puede llamar herramientas es software con un núcleo no determinista que hace llamadas privilegiadas, y te toca modelar sus amenazas como tal. Esta guía recorre los ataques que de verdad importan cuando un agente puede actuar (prompt injection, confused deputy, exfiltración de datos) y después arma la contención de mayor a menor eficacia: mínimo privilegio, aprobación humana en las acciones peligrosas y un muro sólido entre el contexto confiable y el no confiable.

25 abr 202613 min de lectura