Fundamentos de seguridad
Supabase security checker: haz tú mismo las cinco comprobaciones
Un Supabase security checker lee tu app publicada en lugar de la configuración de tu proyecto. Estas son las cinco comprobaciones y cómo hacer cada una.

En resumen
- Un Supabase security checker lee tu app en vivo igual que la lee un desconocido: el bundle que entrega, las respuestas de tus tablas, tus buckets de almacenamiento, tus cabeceras.
- El Security Advisor de Supabase lee la otra mitad, que es la configuración de tu proyecto. Ninguno de los dos ve lo que ve el otro.
- Cinco comprobaciones cubren la vista desde fuera. Todas se ejecutan desde una terminal, no piden cuenta y juntas llevan unos diez minutos.
Buscaste un Supabase security checker y te salieron nueve. Uno quiere tu repositorio. Otro quiere una extensión de navegador. Otro quiere acceso de lectura a toda tu cuenta de Supabase, que se parece bastante a aquello que te preocupaba como para hacerte dudar.
Esto es lo que ninguna de esas páginas de producto dice en voz alta. Son cinco comprobaciones, y puedes hacer todas tú mismo, sin cuenta, sin instalar nada y sin ninguna de las credenciales que sería peligroso entregar. Diez minutos en una terminal cubren el mismo terreno que las herramientas.
Las cinco de abajo son las que nuestro propio escáner ejecuta contra un proyecto de Supabase, escritas como comandos. Si construiste con Lovable, Bolt, v0, Cursor, Replit, Windsurf o Base44 y tu app guarda cualquier cosa, lo que hay detrás es muy probablemente Supabase, y estas son las preguntas que le haría un desconocido.
¿Qué comprueba realmente un Supabase security checker?
Tu app publicada. Los ajustes dentro de tu proyecto son trabajo de otra herramienta, y Supabase ya trae esa herramienta.
El Security Advisor de tu panel lee el catálogo del propio proyecto: qué tablas tienen Row Level Security desactivado, qué funciones llevan un search path laxo, qué extensiones viven en el esquema público. Está leyendo lo que configuraste.
Un checker lee lo que tu app le entrega a un desconocido. El bundle de JavaScript que descargan tus visitantes, la respuesta que da una tabla a una petición sin sesión, el contenido de un bucket de almacenamiento, las cabeceras de tus páginas. De tu configuración no ve nada, y no le hace falta, porque está mirando la consecuencia.
Las dos mitades se separan más a menudo de lo que sugieren los nombres. Una tabla puede pasar el Advisor con el interruptor puesto y una política escrita, y aun así entregar sus filas a cualquiera, porque la política que se escribió deja entrar a todo el mundo. Eso merece un artículo aparte si es nuevo para ti: el interruptor no es la protección.
Comprobación 1: ¿qué clave de Supabase entrega tu app?
Abre tu app en un navegador, pulsa F12 y mira una sola petición.
Ve a la pestaña Red, recarga la página y escribe supabase en el filtro. Tu app
hará al menos una petición a una dirección que acaba en .supabase.co. Haz clic
en ella. Ahí dentro están las dos cosas que necesitas para el resto del
artículo:
- La dirección de la petición empieza con la URL de tu proyecto, algo como
https://abcdefghij.supabase.co. Las comprobaciones 2 y 3 apuntan ahí. - La cabecera de petición
apikeylleva la clave que tu app entrega a cada visitante.
Copia las dos a algún sitio y luego lee la clave. Una que empieza por
sb_publishable_ pertenece a tu app y no hay nada que arreglar. Una que empieza
por sb_secret_ no pertenece nunca, y encontrarla termina la lista antes de
tiempo: rótala hoy, antes que cualquier otra cosa de esta página. Los proyectos
antiguos llevan un par sin prefijo legible, anon y service_role, y
distinguirlas significa
leer el rol dentro de la clave.
Ese último caso es raro. De las 30.998 apps en vivo que escaneamos en agosto de 2026, una clave secreta de Supabase en el navegador apareció tres veces.
Mientras tengas abierta la pestaña Red, anota los nombres de tabla que veas en esas direcciones. La siguiente comprobación los necesita.
Comprobación 2: ¿responden tus tablas a un desconocido?
Pregúntale a la base de datos cuántas filas le daría a alguien sin sesión, y lee el número en la respuesta.
curl -s -i \
-H "apikey: TU_CLAVE_PUBLICABLE" \
-H "Prefer: count=exact" \
-H "Range: 0-0" \
"https://TU_PROYECTO.supabase.co/rest/v1/profiles?select=count" \
| grep -i content-range
Pon la clave y la dirección del proyecto de la comprobación 1, y tu propio
nombre de tabla donde dice profiles. curl viene instalado en macOS, en Linux y
en Windows actual, así que no hay nada que descargar antes.
Esa petición no trae ninguna fila. select=count pide un recuento,
Range: 0-0 pide ninguna de las filas en sí, y toda la respuesta llega en una
sola cabecera. Es la misma petición que hace nuestro escáner, y por eso puede
ejecutarse contra la app en vivo de alguien sin tocar sus datos.
Pueden volver cuatro cosas, y una de ellas es un hallazgo:
| Lo que se imprime | Lo que significa |
|---|---|
content-range: */0 | Nada legible sin sesión. Esa tabla está haciendo su trabajo. |
content-range: 0-0/128 | 128 filas al alcance de cualquiera que tenga la clave que viaja en tu página. |
401 o 403 | La clave fue rechazada antes de llegar a la tabla. Sin respuesta, no limpio. |
404 | No hay tabla con ese nombre. O lo adivinaste, o se escribe de otra forma. |
El rechazo es la fila con la que hay que tener cuidado. Un 401 dice que la
petición se detuvo antes de llegar a la tabla, lo que no te dice nada sobre si
la tabla está protegida, y de las cuatro es la más fácil de redondear a una
marca verde.
Ejecuta el comando para cada tabla que tu app nombró en la comprobación 1, y
después para las corrientes que suele tener una app: users, profiles,
customers, orders, messages, invoices. En esas seis viven los datos de
otras personas.
Es la comprobación que más encuentra. De las 3.680 apps con Supabase donde pudimos completarla, 2.096 tenían al menos una tabla que respondía a una petición sin sesión, y en 394 de ellas la tabla abierta llevaba nombre de personas. La medición completa y de qué es una proporción está en un artículo aparte.
Comprobación 3: ¿lista un bucket sus propios archivos?
Pídele a un bucket su contenido y mira si vuelve algo.
curl -s -X POST \
-H "apikey: TU_CLAVE_PUBLICABLE" \
-H "Content-Type: application/json" \
-d '{"prefix":"","limit":1}' \
"https://TU_PROYECTO.supabase.co/storage/v1/object/list/avatars"
Envía el body. Un POST a esa dirección sin nada dentro responde "Body cannot be empty" para cualquier bucket de cualquier proyecto, esté configurado como esté. Lo sabemos porque nuestra propia comprobación de buckets se publicó así y pasó meses informando alegremente de que nada era listable.
Dos formas de respuesta:
[]significa que el bucket está cerrado, o que no hay ningún bucket con ese nombre. Desde fuera no puedes saber cuál de las dos, así que una lista vacía no es prueba de nada.- Un array con un archivo dentro significa que un desconocido puede pedirle a tu proyecto un directorio de ese bucket y recibirlo.
Prueba los nombres de bucket que usó tu app, y después los habituales:
avatars, public, uploads, files, images, documents.
Listable y público son dos ajustes distintos guardados en dos sitios distintos, y la diferencia decide cuánto importa un bucket público. El listado es el que hay que cerrar, porque le ahorra a un desconocido el trabajo de adivinar un nombre de archivo. De las 27.269 apps donde pudimos preguntar, 792 respondieron con una lista.
Comprobación 4: ¿se publica tu código fuente junto a tu app?
Lee la última línea de tu bundle de JavaScript.
De vuelta en la pestaña Red de la comprobación 1, busca el archivo .js más
grande que cargó tu app y copia su dirección. Después:
curl -s "https://tu-app.example/assets/index-abc123.js" | tail -c 120
Una última línea que diga //# sourceMappingURL=index-abc123.js.map significa
que tu build escribió un source map y le dijo a los navegadores dónde está. Si
además lo publicó lo aclara una petición más:
curl -s -o /dev/null -w "%{http_code}\n" \
"https://tu-app.example/assets/index-abc123.js.map"
200 significa que cualquiera puede descargarlo. Un source map convierte el
JavaScript comprimido de vuelta en tus archivos originales, con tu estructura de
carpetas, tus comentarios y tu lógica intactos. Lo que expone es tu código, y
cualquier clave que revele ya estaba en el bundle a su lado, que es de lo que
iba la comprobación 1. 3.885 de las 30.987 apps que pudimos revisar publican el
suyo, y en algunos builders eso es un ajuste por defecto de la plataforma y no
una decisión de nadie:
qué muestra un source map publicado.
Comprobación 5: qué envía tu hosting con cada página
Una petición a la dirección de tu propia app, leyendo lo que volvió con ella.
curl -s -i "https://tu-app.example" | grep -i -E \
"content-security-policy|strict-transport-security|x-frame-options|x-content-type-options|referrer-policy"
Cinco cabeceras, y las que no se imprimen son las que te faltan. Le dicen a un navegador que rechace la página si se carga dentro del sitio de otro, que la próxima vez insista en una conexión cifrada, y que deje de adivinar qué tipo de archivo acaba de recibir.
Casi todas las apps suspenden esta y casi nadie puede hacer nada al respecto. A 30.756 de las 30.981 apps que pudimos revisar les faltaba al menos una, porque las envía la plataforma de hosting y quien tiene su app en un subdominio de un builder no tiene dónde cambiarlas. Lo contamos y lo limitamos a medio: qué predicen y qué no las cabeceras que faltan.
Cómo leer lo que encontraste
Por gravedad, que no es el orden en que las ejecutaste.
Una clave secreta en el bundle va primero. Se salta todas las reglas que escribiste en todas las tablas, así que rótala antes de mirar cualquier otra cosa de aquí.
Una tabla llena de personas que responde a la comprobación 2 va después. En
users, profiles, orders y messages están los datos de otra gente, y
ahora mismo se puede llegar a ellos. Una tabla products que responde igual
puede estar perfectamente bien, y tú eres la única persona capaz de distinguir
las dos.
Luego un bucket listable, luego los maps y las cabeceras, que suelen ser los valores por defecto de tu plataforma y no algo que elegiste.
Una cosa que llevarte de las cinco. Una comprobación que no obtuvo respuesta
no ha aprobado. Un 401, una petición que agotó el tiempo, una tabla cuyo
nombre adivinaste mal: cada una de esas es una pregunta que sigue abierta.
Nuestro escáner escribe "No se pudo comprobar" en esas líneas y deja la nota
como está, y hacerlo a mano significa llevar esa lista tú.
Todo esto describe además la app que estaba en vivo en el momento en que preguntaste. Row Level Security se desactiva para que cargue una página, un bucket se abre para una subida, una clave se pega para que una función salga esta noche.
Qué hacer esta semana
Qué hacer
- Ejecuta la comprobación 2 contra cada tabla que nombra tu app, y después contra
users,profiles,customers,ordersymessages. Ahí están los hallazgos. - Si la comprobación 1 sacó una clave secreta, rótala primero. Borrarla de tu código deja el valor antiguo funcionando para quien ya lo tenga.
- Anota cada sonda que recibió un rechazo o ninguna respuesta. Esas quedan sin responder, y una pregunta sin responder no es un resultado limpio.
- Deja en paz las tablas que deben ser públicas. Una lista de productos que responde a un desconocido es tu app funcionando bien.
- Vuelve a ejecutar las cinco después de un deploy y después de que alguien cambie una regla de la base de datos. Ninguna de las dos cosas se anuncia sola.
Nuestro escáner de seguridad gratuito ejecuta estas cinco y cuatro más contra cualquier URL en vivo en unos veinte segundos, sin cuenta y sin que entregues credenciales. Lee, nunca escribe, y donde no consigue respuesta lo dice en la línea.
Las cinco puedes hacerlas tú hoy. Lo que ninguna pasada suelta puede decirte es si las respuestas siguen siendo las mismas el mes que viene, y para eso construimos Reeve Care: repite estas comprobaciones según un calendario, te escribe cuando una respuesta empeora, y guarda copias verificadas de tu base de datos de Supabase, además de los archivos que suben tus usuarios en cuanto conectas una credencial de almacenamiento. Qué vigila y cuánto cuesta.
Si una terminal no es donde quieres estar, la guía en lenguaje claro para apps con Supabase explica para qué sirve cada uno de estos ajustes y dónde encontrarlo en el panel.
Preguntas frecuentes
¿El Security Advisor de Supabase lo detecta todo?
Detecta todo lo de su propia mitad. El Advisor lee la configuración de tu proyecto e informa de tablas con Row Level Security desactivado, funciones con un search path laxo y ajustes parecidos que controlas desde el panel. Lo que no puede ver es tu app publicada: qué clave acabó en el JavaScript que descargan tus visitantes, si una política que escribiste detiene de verdad una petición anónima, o qué cabeceras envía tu hosting. Ejecuta el Advisor y las cinco comprobaciones de este artículo, porque miran cosas distintas.
¿Es seguro ejecutar estas comprobaciones contra mi propio proyecto?
Sí. Todos los comandos de aquí leen y ninguno escribe. La comprobación de tablas le pide a tu base de datos un recuento de filas y lee el número de una cabecera de respuesta, así que nunca se descarga una fila. La de buckets pide un listado y se detiene ahí, sin descargar ningún archivo. Las dos últimas son una petición de página corriente, del tipo que tus visitantes hacen todo el día. Ejecutarlas contra un proyecto que no es tuyo es otra cuestión, y la respuesta es pedir permiso primero.
Mi anon key está en mi bundle. ¿Es un problema?
No. La anon key en proyectos antiguos, y sb_publishable_ en los nuevos, está pensada para estar en el código que descargan tus visitantes. Nombra tu proyecto y por sí sola no concede nada; Row Level Security es lo que decide qué filas recibe una petición. La clave que nunca debe estar ahí es la secreta, llamada service_role en proyectos antiguos y sb_secret_ en los nuevos, porque se salta todas las reglas que escribiste.
¿Qué significa content-range: */0?
Es tu base de datos diciendo que a este solicitante le entregará cero filas de esa tabla. El asterisco significa que la respuesta no contiene fila alguna, y el número después de la barra es el recuento que el solicitante puede ver. Así que */0 es la respuesta que quieres de una tabla con datos personales, y 0-0/128 significa que hay 128 filas al alcance de cualquiera que tenga la clave que viaja en tu app.
¿Necesito mi service_role key para probar esto?
No, y un checker que te la pida está pidiendo lo que no debe. Todas las comprobaciones de aquí usan la clave publicable que ya está en tu app, porque esa es la clave que tendría un desconocido. Probar con una clave secreta te dice qué alcanza un administrador, que nunca estuvo en duda. No pegues una clave service_role ni sb_secret_ en ningún escáner, incluido el nuestro: nada de lo que hacemos la necesita.