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.

En resumen
- Redacta una política para CADA UNA de SELECT / INSERT / UPDATE / DELETE. El hueco silencioso casi siempre es una operación que falta o que quedó permisiva por default.
- INSERT y UPDATE necesitan una cláusula WITH CHECK, no solo USING. El USING controla qué filas puedes tocar; el WITH CHECK controla cómo puede quedar la fila después.
- El aislamiento por tenant tiene que salir de auth.uid() o de un claim del JWT, nunca de una columna que pone el cliente y que el usuario puede fijar en el valor que quiera.
- La sección de NOTAS DE AMENAZA es lo que importa: el modelo ataca sus propias políticas y te dice el bypass que cada una todavía deja abierto.
- El prompt redacta; el asesor de seguridad decide. Corre get_advisors después de aplicar y confirma que ninguna tabla quede con RLS deshabilitado.
Row Level Security es la capa que hace seguro exponer Supabase directo al navegador, y también es donde una cantidad sorprendente de apps se filtra sin hacer ruido. Las políticas se leen bien en el review. Después un usuario descubre que puede actualizar el tenant_id de una fila y moverla a la cuenta de otro, porque cuidaste qué filas podías tocar pero no en qué se podía convertir la fila. Esta página te entrega el prompt que usamos para redactar el set completo de políticas de una tabla, una política por operación, y, más importante, para hacer que el modelo le haga red-team a su propia salida en una sección de NOTAS DE AMENAZA antes de que la despliegues. Te llevas el template, las variables que tienes que rellenar, variantes para multi-tenant y storage, y los modos de fallo que convierten una política de aspecto impecable en una lectura cruzada entre tenants.
01 · Por qué RLS se filtra aun cuando la política "se ve bien"
La mayoría de los bugs de RLS no tienen nada de exótico. Caen en un puñado de patrones, y todos sobreviven a una lectura rápida porque el SQL es sintácticamente válido y el happy path funciona.
- La operación olvidada. Escribes una política de SELECT bien ajustada, pruebas que un usuario solo ve sus filas, y lo das por terminado. Pero INSERT, UPDATE y DELETE necesitan cada una su propia política. Con RLS activado y sin política para una operación, esa operación queda negada, algo seguro, pero que rompe tu app, así que la gente recurre a un USING (true) amplio para desbloquearla y abre un hueco sin darse cuenta.
- USING vs. WITH CHECK. Esta es la filtración real más común. El USING decide qué filas existentes puede ver o tocar un statement. El WITH CHECK decide qué filas pueden existir después de un INSERT o un UPDATE. Si cuidas un UPDATE solo con USING, un usuario puede leer únicamente sus propias filas, pero también reescribir una para ponerle el tenant_id de una víctima, porque nada valida la fila nueva.
- Confiar en columnas que pone el cliente. Si el aislamiento depende de una columna user_id u org_id que el cliente manda en el payload, el cliente puede mandar el valor que quiera. El aislamiento tiene que salir de algo que el usuario no pueda falsificar: auth.uid() o un claim verificado del JWT.
- El join demasiado amplio. Una política que comprueba la membresía con un subquery puede dar acceso por accidente a toda fila cuyo padre el usuario alcance a ver, incluidas filas que nunca debió leer.
Importante
"La política se ve correcta" no es una propiedad de seguridad. Una política es correcta solo en relación con un modelo de amenaza, quién es el atacante, qué puede mandar y cuál columna le encantaría sobrescribir. El propósito entero del prompt de abajo es forzar ese modelo de amenaza al papel en vez de dejarlo en tu cabeza.
02 · El prompt redactor de políticas RLS
Aquí está el template. Hace dos cosas en una sola pasada: redacta una política para cada operación y luego ataca su propio borrador. Pégalo en Claude (o úsalo como system prompt de un subagente redactor) y después rellena los tres bloques entre corchetes de arriba. No quites la sección de NOTAS DE AMENAZA, es la razón por la que el prompt existe.
Eres un autor de políticas de Row Level Security de PostgreSQL trabajando sobre
Supabase. Vas a recibir una tabla, una intención de acceso y el modelo de
identidad. Redacta las políticas RLS y luego ataca tu propio trabajo.
TABLA (DDL):
[pega el CREATE TABLE, con columnas, tipos y foreign keys]
INTENCIÓN DE ACCESO (en lenguaje claro, sé específico):
[quién puede leer cuáles filas; quién puede crear/editar/borrar; cualquier rol
admin o de servicio; cualquier fila que un usuario NO deba poder crear ni mover]
MODELO DE IDENTIDAD:
[cómo se identifica a un usuario — auth.uid(); cuál claim del JWT lleva el
tenant/org si lo hay; cuál tabla mapea un usuario con un tenant/membresía]
Reglas que DEBES seguir:
- Activa RLS en la tabla y FUÉRZALO; asume que los roles anon y authenticated
llegan a esta tabla directo desde el cliente.
- Escribe una política SEPARADA para CADA UNA de SELECT, INSERT, UPDATE, DELETE.
Si una operación debe negarse por completo, dilo explícitamente y no crees
política para ella (con RLS activado, sin política = negada) — NO la tapes con
un USING (true).
- El aislamiento por tenant/dueño DEBE salir de auth.uid() o de un claim
verificado del JWT, NUNCA de una columna que el cliente ponga en la fila.
- Para INSERT: incluye una cláusula WITH CHECK. Para UPDATE: incluye TANTO una
cláusula USING (a cuáles filas se puede apuntar) COMO una cláusula WITH CHECK (en
qué se puede convertir la fila). Explica en una línea por qué hace falta cada una.
- Prefiere (select auth.uid()) sobre auth.uid() para que el planner lo cachee por
statement en lugar de evaluarlo por fila.
- No le des acceso al service_role mediante una política; ese rol salta RLS por
diseño. Si un camino del lado del servidor necesita acceso amplio, anota que
debe usar la service key en un servidor confiable, nunca en el navegador.
SALIDA, exactamente en este orden:
1. El SQL — activar + forzar RLS, luego una política por operación, cada una
precedida por un comentario de una línea que nombre la operación y la regla
que hace cumplir.
2. OPERACIONES NEGADAS — lista cualquier operación sin política y por qué queda negada.
3. NOTAS DE AMENAZA — para CADA política, describe cómo un usuario AUTENTICADO
malicioso podría abusar de ella: el statement exacto que correría, la columna
que manipularía, la fila que alcanzaría. Luego da el arreglo de una línea. Si de
verdad no encuentras un abuso para una política, di "No se encontró bypass:" y
declara el ataque específico que intentaste y por qué falla.
4. VERIFICAR — la comprobación o las dos comprobaciones que debería correr tras
aplicar (p. ej. el asesor a correr, un SELECT a intentar como otro tenant) para
confirmar que las políticas aguantan.
Tres decisiones de diseño cargan con el peso:
- "Una política por operación, niega explícitamente" mata el bug de la operación olvidada. El modelo tiene que dar cuenta de los cuatro verbos y nombrar cuáles está dejando negados, así una operación faltante se vuelve una decisión visible en lugar de un accidente.
- "USING y WITH CHECK en UPDATE, con una razón para cada uno" fuerza la distinción que causa las peores filtraciones. Pedir la razón evita que el modelo pegue un WITH CHECK que solo repite al USING y reabre el hueco sin que se note.
- El contrato de las NOTAS DE AMENAZA, "encuentra el abuso o nombra el ataque que intentaste", evita que el modelo le ponga el sello a su propio SQL sin revisarlo. Un "se ve seguro" vacío no cuenta como salida válida; tiene que mostrar el ataque que corrió.
03 · Leer las NOTAS DE AMENAZA (donde de verdad aprendes algo)
El SQL es la parte aburrida, en eso el modelo se luce. El valor está en la sección 3, porque ahí es donde el modelo te dice el bypass que habrías desplegado. Un ejemplo real: pídele que redacte políticas para una tabla documents que pertenece a usuarios dentro de un org, y las NOTAS DE AMENAZA muchas veces van a sacar algo como esto.
-- BORRADOR: actualiza solo tus propios documentos
create policy "doc_update_own"
on documents for update
to authenticated
using ( (select auth.uid()) = owner_id );
-- ^ NOTA DE AMENAZA: solo USING. Un usuario corre
-- update documents set org_id = '<org-victima>' where owner_id = auth.uid();
-- Sigue "siendo dueño" de la fila, así que el USING pasa, y nada revisa el
-- org_id NUEVO. El documento salta en silencio a otro org.
-- ARREGLADO: fija TANTO las filas que puedes tocar COMO en qué se convierte la fila
create policy "doc_update_own"
on documents for update
to authenticated
using ( (select auth.uid()) = owner_id )
with check (
(select auth.uid()) = owner_id
and org_id = (select org_id from members where user_id = (select auth.uid()))
);
Ese WITH CHECK es la diferencia entre una app que aísla tenants y una que deja a cualquier usuario autenticado reasignar sus propios datos a la cuenta de otro. Vas a ver el mismo tipo de hallazgo para INSERT (un usuario insertando una fila ya marcada con el org_id de una víctima) y para el SELECT con un join demasiado amplio.
Atención
Trata las NOTAS DE AMENAZA como una checklist por verificar, no como una garantía. El modelo está razonando sobre SQL que él mismo acaba de escribir; puede pasar por alto un camino, sobre todo uno que dependa de un trigger, un default o la política de una segunda tabla. Si la seguridad de una fila depende de tablas relacionadas, vuelve a correr el prompt incluyéndolas.
04 · Variables, variantes y cómo adaptarlo
El template tiene tres bloques que rellenas por cada tabla, más unas cuantas palancas para distintas formas de app.
Las variables que rellenas
- «TABLA (DDL)», pega el CREATE TABLE real, no un parafraseo. Los tipos de columna y los foreign keys son lo que le permite al modelo razonar sobre cuáles columnas se pueden falsificar y cuáles joins son seguros. Un resumen esconde justo el detalle que termina filtrando.
- «INTENCIÓN DE ACCESO», las reglas en lenguaje claro, lo más específicas que puedas. La línea más útil que puedes agregar es la negativa: "un usuario nunca debe poder crear ni mover una fila hacia otro org." Esa frase es contra la que atacan las NOTAS DE AMENAZA.
- «MODELO DE IDENTIDAD», cómo se establece la identidad y dónde vive la membresía del tenant. Si el aislamiento pasa por una tabla members o memberships, nómbrala; el modelo la necesita para escribir el subquery y para razonar si ese subquery es seguro de por sí.
Variantes
Multi-tenant por org, no por dueño. Cambia las comprobaciones de dueño por búsquedas de membresía y agrega una línea a la intención: "el aislamiento es por org; un usuario puede ver todas las filas de su org pero solo editar las que creó." Las NOTAS DE AMENAZA tienen entonces que considerar a un atacante del mismo org, una amenaza distinta, y que muchas veces se pasa por alto, frente a la de un atacante de otro org.
Supabase Storage. Las políticas de storage viven en storage.objects y se apoyan en la ruta del objeto. Cambia el bloque de DDL por "el nombre del bucket y la convención de ruta (p. ej. {org_id}/{user_id}/archivo)" y dile al modelo cuál segmento de la ruta codifica el tenant. El bug clásico que debería atrapar: una comprobación de prefijo de ruta que un usuario burla nombrando su propia subida con el prefijo de una víctima.
Caminos de service-role / servidor. Agrega a la intención: "una Edge Function que usa la service key necesita leer todas las filas para un job nocturno." En ese caso el modelo no debería escribir una política, el service role salta RLS, sino anotar que ese acceso tiene que quedarse del lado del servidor. Esto frena el error común de debilitar una política de cara al cliente para acomodar un job del servidor.
Consejo
Usa (select auth.uid()) en lugar de auth.uid() pelado dentro de las políticas. Postgres cachea el resultado del subselect una vez por statement en vez de llamar a la función fila por fila, que es la diferencia entre una política que escala y una que convierte cada query sobre una tabla grande en un scan. El prompt ya pide esto; consérvalo cuando edites a mano.
05 · Detalles que convierten una política limpia en una filtración
Estas son las formas en que el patrón falla en la práctica. Cada una parece inofensiva y cada una es un bypass real que hemos visto.
-
Confiar en el prompt por encima del asesor. Las NOTAS DE AMENAZA del modelo son una primera pasada sólida, no un veredicto. Después de aplicar, corre el asesor de seguridad de Supabase, desde el dashboard o con get_advisors, y confirma que ninguna tabla quede con RLS deshabilitado y que ninguna política aparezca marcada. El asesor atrapa la tabla en la que ni siquiera activaste RLS, algo que ningún prompt por tabla va a atrapar.
-
Un WITH CHECK que solo copia al USING. Un WITH CHECK idéntico al USING en un UPDATE casi siempre está mal, deja que el usuario conserve la propiedad mientras le cambia una columna de tenant. El CHECK tiene que restringir las columnas que definen el aislamiento en la fila nueva, no solo reafirmar la propiedad.
-
Dejar una operación permisiva por default para "desbloquear" la app. Cuando una política faltante niega una operación que tu app necesita, el arreglo rápido es un USING (true) amplio, y así se despliega. Ese es el bug de la operación olvidada disfrazado. Escribe la política de verdad, o decide explícitamente que la operación pertenece solo a un camino del servidor.
-
Falsificar la identidad con una columna del cliente. Si una política lee el org_id de la fila que se está insertando en lugar de la membresía verificada del usuario, el usuario simplemente manda el org que quiera. Las columnas de aislamiento en el WHERE/CHECK tienen que salir de auth.uid() o de un claim del JWT, nunca de los valores new que controla el cliente.
-
El service role en el navegador. La service key salta RLS por completo. Si alguna vez llega al código del cliente, toda política de esta página queda anulada. Mantenla en un servidor confiable, en una Edge Function o en tu backend, nunca en un client component de Next.js ni en una env var pública.
-
Probar solo como tú mismo. Una política que pasa cuando consultas como su dueño no te dice nada sobre el aislamiento. Prueba como un tenant distinto: entra como el usuario B e intenta hacer SELECT, UPDATE y DELETE sobre las filas del usuario A. La filtración solo se ve desde el asiento del atacante.
Corre el prompt, lee las NOTAS DE AMENAZA con ojos de atacante, arregla lo que saque y deja que el asesor tenga la última palabra. El valor no está en que el modelo escriba SQL perfecto. Está en que dejas de desplegar políticas cuya única prueba fue verse bien en el review. Una política que intentaste romper a propósito vale muchísimo más que una que apenas compiló.
Puntos clave
- Redacta una política por operación y nombra las que niegas, la operación olvidada o permisiva por default es el leak silencioso más común.
- UPDATE e INSERT necesitan WITH CHECK, no solo USING: el WITH CHECK es la cláusula que impide que un usuario reasigne una fila a otro tenant.
- Saca el aislamiento de auth.uid() o de un claim verificado del JWT, nunca de una columna que el cliente pueda poner en la fila.
- La sección de NOTAS DE AMENAZA es la recompensa, haz que el modelo ataque sus propias políticas y nombre el bypass, y luego arregla lo que saque.
- El prompt redacta; el asesor decide. Corre get_advisors y prueba como un tenant distinto antes de confiar en una política.
Preguntas frecuentes
¿Por qué redactar una política separada por operación en vez de una sola política FOR ALL?
Porque las cuatro operaciones necesitan una lógica realmente distinta, y FOR ALL eso lo esconde. SELECT y DELETE solo llevan cláusula USING; INSERT solo lleva WITH CHECK; UPDATE necesita las dos, y muchas veces difieren (puede que dejes a un usuario leer todas las filas de su org pero editar solo las propias). Una sola política FOR ALL obliga a una sola expresión a cubrir todo eso, y casi siempre termina demasiado amplia o equivocada en el UPDATE. Separarlas convierte cada operación en una decisión explícita y revisable.
¿Cuál es la diferencia real entre USING y WITH CHECK en un UPDATE?
El USING se evalúa contra la fila existente, decide si el statement puede siquiera apuntar a esa fila. El WITH CHECK se evalúa contra la fila nueva, ya con tu SET aplicado, decide si el resultado tiene permitido existir. Con solo USING, un usuario puede actualizar filas que ya posee y ponerles el valor que quiera en cualquier columna, incluido un tenant_id que mueve la fila a otro. El WITH CHECK es lo que valida los valores nuevos, así que es la cláusula que de verdad impide reasignar datos entre tenants.
¿Puedo confiar en las NOTAS DE AMENAZA del modelo, o todavía tengo que probar a mano?
Trátalas como una checklist sólida, no como un veredicto. El modelo razona bien sobre el SQL que tiene delante, pero puede pasar por alto caminos que dependen de un trigger, de un default de columna o de la política de una segunda tabla, contexto que no tiene. Así que aplicas los arreglos que saca y luego verificas por dos vías: corre el asesor de seguridad de Supabase (get_advisors) para atrapar tablas con RLS apagado o políticas marcadas, y prueba como un tenant distinto intentando leer y escribir las filas de otro usuario. El prompt acota la búsqueda; el asesor y una prueba real entre tenants la cierran.
¿Dónde encaja el service_role? ¿No necesito una política para mis jobs del servidor?
No, el service_role salta RLS por completo, por diseño, así que nunca le escribes una política. Los jobs del lado del servidor que necesitan acceso amplio usan la service key, pero solo en un servidor confiable (una Edge Function o tu backend). El error que hay que evitar es debilitar una política de cara al cliente para que funcione un job del servidor; eso también se la abre a usuarios reales. Deja el acceso amplio en la service key y mantén esa key fuera del cliente. Si alguna vez cae en un bundle del navegador o en una env var pública, toda política que escribiste queda en nada.
¿Por qué (select auth.uid()) en vez de solo auth.uid() en la política?
Es un tema de rendimiento, no de seguridad. Un auth.uid() pelado en una política puede evaluarse una vez por cada fila que el query escanea, lo que en una tabla grande se vuelve un costo serio. Envolverlo como (select auth.uid()) deja que el planner lo trate como un escalar estable y cachee el resultado una sola vez por statement. El comportamiento de seguridad es idéntico; el query simplemente deja de recalcular el mismo user id miles de veces. Es un hábito chiquito que evita que las tablas protegidas por RLS se pongan lentas a medida que crecen.
La app se rompe después de que activo RLS, ¿el prompt está mal?
Casi siempre significa que una operación no tiene política, así que RLS la niega, y ese es el default seguro funcionando como debe. La reacción equivocada es pegarle un USING (true) amplio para desbloquearla; ese es el leak de la operación olvidada disfrazado. La reacción correcta es averiguar cuál operación está fallando y, o bien escribirle la política de verdad, o bien decidir que esa operación pertenece solo a un camino del servidor con la service key. La sección de OPERACIONES NEGADAS del prompt existe justo para que estas decisiones queden a la vista antes de que te encuentres con la rotura en producción.
¿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 WhatsAppPrimera conversación gratis. Te responde el fundador.
Recursos relacionados

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.

MCP de Supabase para migraciones seguras por rama
Dejar que un modelo escriba tu migración de esquema no tiene nada de malo; dejar que la aplique directo a producción es la forma perfecta de arruinarte un sábado. El servidor MCP de Supabase le da a Claude una rama de base de datos donde trabajar (diseña, aplica, revisa los advisors y después haces merge) para que una respuesta tan equivocada como segura de sí misma le caiga a una copia desechable y no a tus tablas en producción.

Claves de API con alcance y RLS en Supabase: deja que la base de datos decida quién lee qué
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.