Tu app funciona. Eso no dice nada de quién más puede usarla. Antes de pasar el enlace a clientes de verdad, hay una lista corta de comprobaciones que separan un lanzamiento de una filtración. Ninguna necesita ser experto en seguridad: necesitan media tarde, dos cuentas de prueba y una terminal.
Las herramientas de vibe coding generan apps que se conectan directamente desde el navegador a Supabase o Firebase. Es un buen diseño, pero tiene una consecuencia: el navegador del usuario es territorio enemigo. Todo lo que tu app puede hacer desde ahí, lo puede hacer también cualquiera con la consola del navegador abierta. La seguridad no está en la interfaz; está en las reglas de la base de datos y en el servidor.
Si buscas por qué tu app falla en producción, eso lo contamos en las 9 causas. Esto es otra cosa: la lista para marcar con un bolígrafo antes de lanzar.
Base de datos: cada fila con su dueño
En Supabase, la seguridad a nivel de fila (RLS) decide qué filas puede leer o escribir cada usuario. Con RLS activado y sin políticas, la tabla no devuelve nada por la API pública: cerrada por defecto. Sin RLS, la clave pública lo abre todo. En Firebase el equivalente son las reglas de Firestore y de Storage.
- RLS activado en todas las tablas del esquema público, sin excepción. Supabase incluye un Security Advisor en el panel que avisa de las que no lo tienen.
- Políticas de propiedad, no de sesión.
auth.uid() is not nullsolo comprueba que hay alguien conectado; lo que quieres es(select auth.uid()) = user_id. En Firebase, la misma trampa:request.auth != nulldeja a cualquier usuario registrado leer los datos de todos. - Updates con `WITH CHECK`.
USINGfiltra qué filas puedes tocar;WITH CHECKcontrola cómo pueden quedar. Si la política no restringe una columna, el usuario puede cambiarla en su propia fila: un camporoleoplaneditable desde el navegador es una escalada de privilegios. Esas columnas, fuera del alcance del cliente. - Vistas revisadas. En Postgres las vistas se saltan RLS por defecto. Créalas con
security_invoker = trueo no las expongas. - Nada de reglas de «modo prueba». En Firebase,
allow read, write: if trueo una regla con fecha de caducidad es para el primer día, no para producción.
Cómo probarlo: sin sesión, con la clave pública que ves en el código del navegador, pide una tabla: curl 'https://TU-PROYECTO.supabase.co/rest/v1/pedidos?select=*' -H 'apikey: CLAVE_PUBLICA'. Debe volver []. Después crea dos cuentas, A y B, y desde la consola del navegador con la sesión de A intenta leer, editar y borrar filas de B. Todo debe fallar. Un escáner que te dice «RLS activado» no te dice que las políticas sean correctas; esta prueba sí.
Claves: la pública puede verse, la secreta jamás
Supabase tiene dos familias. La pública (anon en el sistema antiguo, sb_publishable_… en el nuevo) está pensada para ir en el navegador y respeta RLS. La secreta (service_role o sb_secret_…) se salta todas las políticas. Supabase lo dice sin rodeos: nunca en un navegador, en una app publicada ni en el control de versiones.
- Busca la clave secreta en el bundle. Abre la web en producción, pestaña Sources o Network de las herramientas de desarrollador, y busca
service_roleysb_secret. También en el repositorio:git grep -n service_role. - Revisa las variables con prefijo público. Todo lo que empieza por
NEXT_PUBLIC_oVITE_acaba en el navegador. Si ahí hay algo que no sea la URL y la clave pública, está expuesto. - Claves de terceros igual. OpenAI, Stripe secreta, Resend, Google Maps sin restringir: fuera del cliente.
- Si una clave secreta ha estado expuesta, rótala. Moverla de sitio no basta: quien la copió la sigue teniendo.
Funciones de servidor: no te fíes de lo que llega
Las Edge Functions de Supabase, las Cloud Functions de Firebase o las rutas de API de Next.js suelen usar la clave secreta. Eso significa que lo que compruebe la función es la única barrera.
- El usuario sale del token, nunca del cuerpo. Si la función recibe
{ userId: "…" }y actúa en su nombre, cualquiera puede mandar otrouserId. - Permisos dentro de la función. Que el usuario esté identificado no quiere decir que pueda borrar ese pedido o ver esa factura.
- `verify_jwt = false` solo donde haga falta, como un webhook de Stripe, y en ese caso verificando la firma del webhook.
- Errores sin detalles internos. Un
500con el SQL o la traza completa le enseña tu esquema a quien pruebe.
Cómo probarlo: llama a cada función con curl sin cabecera de autorización, con el token de otro usuario y con identificadores ajenos en el cuerpo. Las tres llamadas deben fallar.
Almacenamiento: «público» significa público
En Supabase Storage, un bucket público deja descargar cualquier fichero a quien tenga la URL. Subir, borrar o mover sigue controlado por políticas en storage.objects, pero leer no.
- Público solo para lo que de verdad lo es: logos, imágenes de producto, avatares si te parece bien.
- Facturas, DNI, nóminas, contratos: bucket privado y enlaces firmados con caducidad corta, generados después de comprobar permisos.
- Rutas con el id del dueño (
user_id/fichero.pdf) y políticas que lo exijan, para que A no pueda subir ni sobrescribir en la carpeta de B. - Límite de tamaño y tipo de fichero en la configuración del bucket.
Cómo probarlo: copia la URL de un documento privado, ábrela en una ventana de incógnito y debe dar error. Con la sesión de A, intenta subir un fichero a la ruta de B.
Login: verificación y redirecciones
- Confirmación de email activada. Supabase la incluye en su checklist de producción. Sin ella, cualquiera se registra con el correo de otro; y si alguna política o regla se fía del email, peor (Firebase recomienda exigir
email_verifieden ese caso). - URLs de redirección exactas en producción. Supabase recomienda la ruta exacta para el Site URL y reservar los comodines para entornos de prueba. Borra las URLs de la vista previa de Lovable o Bolt que ya no uses.
- SMTP propio, para que los correos lleguen desde tu dominio y no parezcan phishing.
- Registro abierto solo si debe estarlo. Una herramienta interna no necesita que se apunte cualquiera de internet.
- Caducidad de enlaces y códigos razonable. Supabase recomienda una hora o menos para los OTP.
Límites: que un bot no te arruine la noche
- Límite por usuario y día en cada función que llame a un modelo de IA, envíe correos o SMS, o cueste dinero por llamada.
- CAPTCHA en registro, inicio de sesión y recuperación de contraseña. Supabase lo soporta en esos tres puntos.
- En Firebase, App Check: comprueba que la petición viene de tu app y no de un script. Complementa las reglas; no las sustituye.
- Alertas de gasto en cada proveedor que facture por uso.
Cómo probarlo: un bucle de cien llamadas seguidas a tu función más cara. Debería empezar a rechazarlas mucho antes de la centésima.
El repositorio: secretos y dependencias
- `.env` en `.gitignore` y fuera del historial. Compruébalo con
git log --all -- .env. Si sale algo, no basta con borrarlo: GitHub recomienda rotar la credencial de inmediato, porque limpiar el historial es lento y no deshace la exposición. - Escaneo de secretos activado. GitHub lo hace gratis en repositorios públicos; para los privados, herramientas como
gitleakssirven igual en local. - `npm audit` y revisión de lo que la IA añadió: paquetes que no se usan, nombres raros, versiones fijadas hace años.
- Lockfile en el repo, para que producción instale exactamente lo que probaste.
Cabeceras: la última capa, no la primera
Las cabeceras HTTP no arreglan una base de datos abierta, pero cierran ataques en el navegador. Se configuran en Vercel, Netlify o tu servidor en un par de líneas.
- `Strict-Transport-Security` para forzar HTTPS.
- `Content-Security-Policy` con los orígenes que de verdad usas, incluido
frame-ancestorspara que nadie meta tu app en un iframe. - `X-Content-Type-Options: nosniff` y `Referrer-Policy` razonable.
- CORS de tus funciones limitado a tu dominio, no
*.
Cómo probarlo: curl -I https://tudominio.com y mira qué cabeceras vuelven. Si no aparece ninguna de las de arriba, no las tienes.
Si solo tienes una tarde
- La prueba de las dos cuentas sobre todas las tablas y buckets.
- Buscar la clave secreta y cualquier clave de terceros en el navegador y en el historial de git.
- Llamar a cada función sin sesión y con la sesión de otro.
Esas tres cubren lo que más daño hace. El resto es importante, pero una tabla abierta es una filtración el día uno; una cabecera de menos, no.
Que la app funcione demuestra que hace lo que quieres. La seguridad es demostrar que no hace lo que no quieres.