StackPractices
beginner Por Mathias Paulenko

Proteger APIs con encabezados de seguridad HTTP

Cómo configurar headers de seguridad esenciales como HSTS, CSP y X-Frame-Options para proteger APIs y aplicaciones web de ataques comunes.

Temas: security

Visión general

Los encabezados de seguridad HTTP son una capa de defensa ligera del lado del servidor que instruye a los navegadores cómo manejar tu contenido. No requieren cambios en el código de aplicación y protegen contra clases enteras de ataques: clickjacking vía X-Frame-Options, cross-site scripting vía Content-Security-Policy, ataques de downgrade de protocolo vía Strict-Transport-Security, y sniffing de MIME-type vía X-Content-Type-Options.

El OWASP Secure Headers Project mantiene una referencia dedicada para estos headers porque son útiles, fáciles de implementar y se olvidan con frecuencia en los despliegues. Un servidor sin estos headers no es inmediatamente vulnerable, pero es considerablemente menos resistente a los ataques web habituales. Para un endurecimiento más amplio de la aplicación, consulta la guía de buenas prácticas de seguridad.

Cuándo usarlo

Usa esta receta cuando:

  • Lanzas una nueva aplicación web o API a producción
  • Realizas auditorías de seguridad o pruebas de penetración
  • Endureces aplicaciones existentes después de una revisión de seguridad
  • Configuras proxies inversos (Nginx, Apache, CloudFront, Cloudflare)
  • Construyes middleware para aplicaciones Express, FastAPI o Spring Boot

Omítelos si todos tus consumidores son APIs máquina a máquina o apps nativas — los headers son instrucciones del navegador y un cliente no-web los ignora.

Solución

Express.js Middleware

const helmet = require('helmet');
const express = require('express');
const app = express();

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "https://trusted-cdn.com"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"],
    },
  },
  strictTransportSecurity: {
    maxAge: 63072000, // 2 años, según recomendación OWASP
    includeSubDomains: true,
  },
}));

// Los defaults de Helmet ya configuran COOP, COEP, CORP,
// Origin-Agent-Cluster y X-Content-Type-Options; solo
// personalizas lo que difiere.

Configuración Nginx

server {
    listen 443 ssl;
    server_name api.example.com;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header X-Frame-Options "DENY" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "geolocation=(), microphone=()" always;
    add_header Cross-Origin-Opener-Policy "same-origin" always;
    add_header Cross-Origin-Resource-Policy "same-origin" always;
    add_header X-Permitted-Cross-Domain-Policies "none" always;
}

FastAPI (Python)

from fastapi import FastAPI
from starlette.middleware.base import BaseHTTPMiddleware

class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        response = await call_next(request)
        response.headers["X-Content-Type-Options"] = "nosniff"
        response.headers["X-Frame-Options"] = "DENY"
        response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
        return response

app = FastAPI()
app.add_middleware(SecurityHeadersMiddleware)

Explicación

  • Strict-Transport-Security (HSTS): Indica a los navegadores que siempre usen HTTPS para tu dominio. Previene ataques de SSL stripping donde un man-in-the-middle degrada la conexión a HTTP. Deja preload fuera de la configuración hasta que todos los subdominios sirvan HTTPS; una vez en la lista de preload, la baja es lenta.
  • Content-Security-Policy (CSP): Restringe desde dónde pueden cargarse scripts, estilos, imágenes y otros recursos. Un CSP estricto bloquea scripts inline y dominios externos no autorizados, neutralizando XSS aunque un atacante inyecte marcado. Para una API que solo sirve JSON, default-src 'none' es suficiente.
  • X-Frame-Options: Impide que tu sitio se incruste en un <iframe> en otro dominio. Bloquea ataques de clickjacking donde el atacante superpone iframes invisibles para engañar al usuario. La directiva frame-ancestors de CSP cubre lo mismo con más control.
  • X-Content-Type-Options: Configurar nosniff evita que los navegadores interpreten archivos como un tipo MIME distinto al declarado. Mitiga ataques donde un .txt subido por un usuario se ejecuta como JavaScript.
  • Referrer-Policy: Controla cuánta URL se comparte en el header Referer en peticiones salientes. strict-origin-when-cross-origin envía la URL completa solo en peticiones del mismo origen.
  • Permissions-Policy: Desactiva funciones del navegador que no necesitas (cámara, micrófono, geolocalización, pagos). Una lista vacía por función es el endurecimiento más barato que existe.
  • Cross-Origin-Opener/Resource/Embedder-Policy: El trío COOP/COEP/CORP aísla tu contexto de navegación y declara qué orígenes pueden incrustar tus recursos, bloqueando fugas cross-origin tipo Spectre.
  • Cache-Control: No es un “security header” en sentido estricto, pero no-store en respuestas que llevan tokens o datos personales los mantiene fuera de cachés compartidas y del navegador.

Así viaja un header desde tu servidor hasta el motor de políticas del navegador:

Mermaid flowchart LR diagram

Variantes

HeaderAtaque prevenidoRequerido?Soporte de navegador
HSTSSSL strippingSíUniversal
CSPXSS, inyección de datosSíUniversal
X-Frame-OptionsClickjackingSíUniversal
X-Content-Type-OptionsMIME sniffingSíUniversal
Referrer-PolicyFuga de informaciónRecomendadoUniversal
Permissions-PolicyAbuso de funcionalidadesRecomendadoModerno
Cross-Origin-Opener-PolicyFugas de contexto (Spectre)RecomendadoModerno
Cross-Origin-Resource-PolicyIncrustación no autorizadaRecomendadoModerno
Clear-Site-DataDatos de sesión obsoletosRespuestas de logoutModerno
Cache-Control: no-storeDatos sensibles en cachéSí, para APIsUniversal

Evita los deprecados: X-XSS-Protection (eliminado de los navegadores, habilitaba vulnerabilidades) y Feature-Policy (el nombre antiguo de Permissions-Policy).

Lo que funciona

  • Usa Helmet como base: el middleware Helmet para Express configura defaults sensatos para todos los headers principales con una sola línea de código.
  • Empieza con un CSP restrictivo y relaja gradualmente: comienza con default-src 'self' y agrega dominios solo cuando la funcionalidad se rompe. Un CSP demasiado permisivo es casi inútil.
  • Trata el preload de HSTS como decisión aparte: cuando todos los subdominios sirvan HTTPS, añade la directiva preload y envía el dominio a hstspreload.org. No lo actives por defecto: la baja es lenta y rompe cualquier subdominio que aún necesite HTTP.
  • Incluye headers en todas las respuestas: las páginas de error (404, 500) y las respuestas de API deben llevar los mismos headers que las páginas HTML. Los atacantes también apuntan a las páginas de error.
  • Prueba con securityheaders.com o Mozilla Observatory: estas herramientas escanean tu sitio y califican tu configuración con pasos concretos de remediación.

Errores comunes

  • Usar ALLOW-FROM en X-Frame-Options: los navegadores modernos no soportan este valor. Usa SAMEORIGIN o DENY en su lugar.
  • Habilitar unsafe-inline para scripts en CSP: esto desactiva la protección XSS de CSP. Usa nonces o hashes si los scripts inline son inevitables.
  • Olvidar endpoints de API: los headers de seguridad a menudo se configuran para rutas HTML pero se omiten de las respuestas JSON de la API. Aplícalos globalmente.
  • Configurar HSTS sin HTTPS listo: si tu sitio todavía sirve tráfico HTTP, HSTS lo romperá para usuarios que hayan visitado la versión HTTPS antes.

Soluciones Avanzadas

Configuración de encabezados de seguridad en Spring Boot

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityHeadersConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .headers(headers -> headers
                .contentSecurityPolicy(csp -> csp.policyDirectives(
                    "default-src 'self'; " +
                    "script-src 'self' https://cdn.example.com; " +
                    "style-src 'self' https://fonts.googleapis.com; " +
                    "img-src 'self' data: https:; " +
                    "connect-src 'self' https://api.example.com; " +
                    "frame-ancestors 'none'; " +
                    "base-uri 'self'; " +
                    "object-src 'none'"
                ))
                .httpStrictTransportSecurity(hsts -> hsts
                    .maxAgeInSeconds(63072000)
                    .includeSubDomains(true)
                )
                .contentTypeOptions(opts -> {})
                .frameOptions(frame -> frame.deny())
                .referrerPolicy(referrer -> referrer.policy(
                    org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN
                ))
                .permissionsPolicy(permissions -> permissions.policy(
                    "geolocation=(), microphone=(), camera=(), payment=()"
                ))
            );

        return http.build();
    }
}

Inyección de encabezados con Cloudflare Workers

Inyecta security headers en el edge sin tocar la infraestructura de origen:

export default {
  async fetch(request, env) {
    const response = await fetch(request);

    // Clonar la respuesta para poder modificar headers
    const newResponse = new Response(response.body, response);

    // Security headers
    newResponse.headers.set('Strict-Transport-Security',
      'max-age=63072000; includeSubDomains');
    newResponse.headers.set('X-Content-Type-Options', 'nosniff');
    newResponse.headers.set('X-Frame-Options', 'DENY');
    newResponse.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
    newResponse.headers.set('Permissions-Policy',
      'geolocation=(), microphone=(), camera=()');

    // Setear CSP solo para respuestas HTML
    const contentType = response.headers.get('Content-Type') || '';
    if (contentType.includes('text/html')) {
      newResponse.headers.set('Content-Security-Policy',
        "default-src 'self'; script-src 'self' https://cdn.example.com; " +
        "style-src 'self' https://fonts.googleapis.com; " +
        "img-src 'self' data: https:; connect-src 'self' https://api.example.com");
    }

    return newResponse;
  },
};

CORS preflight con encabezados de seguridad

Combina CORS y security headers para APIs cross-origin:

const express = require('express');
const helmet = require('helmet');
const cors = require('cors');

const app = express();

// Security headers primero
app.use(helmet());

// CORS con allowlist de orígenes específicos
const corsOptions = {
  origin: (origin, callback) => {
    const allowedOrigins = [
      'https://app.example.com',
      'https://admin.example.com',
    ];
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('No permitido por CORS'));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
  exposedHeaders: ['X-Request-ID', 'X-RateLimit-Remaining'],
  credentials: true,
  maxAge: 86400, // Cachear preflight por 24 horas
};

app.use(cors(corsOptions));

// Handler explícito de preflight
app.options('*', cors(corsOptions));

// Rutas de API
app.get('/api/health', (req, res) => {
  res.json({ status: 'ok' });
});

Testing automatizado de encabezados con curl

#!/bin/bash
# audit-headers.sh — Verificar security headers en una URL

URL="${1:-https://example.com}"
REQUIRED_HEADERS=(
  "strict-transport-security"
  "content-security-policy"
  "x-content-type-options"
  "x-frame-options"
  "referrer-policy"
)

echo "Auditando: $URL"
echo "-----------------------------------"

HEADERS=$(curl -sI "$URL")

for header in "${REQUIRED_HEADERS[@]}"; do
  VALUE=$(echo "$HEADERS" | grep -i "^$header:" | sed 's/^[^:]*: *//')
  if [ -z "$VALUE" ]; then
    echo "FALTA: $header"
  else
    echo "OK: $header = $VALUE"
  fi
done

# Verificar CSP débil
CSP=$(echo "$HEADERS" | grep -i "^content-security-policy:" | sed 's/^[^:]*: *//')
if echo "$CSP" | grep -q "unsafe-inline"; then
  echo "ADVERTENCIA: CSP contiene 'unsafe-inline'"
fi
if echo "$CSP" | grep -q "unsafe-eval"; then
  echo "ADVERTENCIA: CSP contiene 'unsafe-eval'"
fi

# Verificar HSTS max-age
HSTS=$(echo "$HEADERS" | grep -i "^strict-transport-security:" | sed 's/^[^:]*: *//')
if echo "$HSTS" | grep -q "max-age=0"; then
  echo "ADVERTENCIA: HSTS max-age es 0 (deshabilitado)"
fi

Ver también

Preguntas frecuentes

¿Los security headers protegen APIs consumidas por apps móviles?

La mayoría de los security headers son específicos de navegadores. Las apps nativas móviles que usan clientes HTTP no se ven afectadas por CSP o X-Frame-Options. Enfócate en autenticación, validación de input y TLS para comunicación API-a-app.

¿Puedo configurar security headers en un CDN como Cloudflare?

Sí. Cloudflare Transform Rules y AWS CloudFront Functions pueden inyectar headers en el edge sin tocar código de origen. Esto es útil para sitios estáticos o sistemas legacy.

¿Cuál es la diferencia entre CSP y CORS?

CSP controla qué recursos puede cargar un navegador cuando renderiza tu página. CORS controla si otros orígenes pueden hacer peticiones a tu API. Son complementarios, no sustitutos.

¿Debería usar report-uri en CSP?

Sigue funcionando en la mayoría de navegadores, pero está deprecado en favor de report-to y la Reporting API. Combina cualquiera de las dos con Content-Security-Policy-Report-Only durante el despliegue para recolectar violaciones sin bloquear nada, y luego pasa a la directiva estricta.

¿Necesito CSP en una API que solo sirve JSON?

Una mínima, sí. default-src 'none' más frame-ancestors 'none' no cuesta nada y elimina la carga de recursos y el framing (carga de recursos, framing) en respuestas que de todos modos no usan funciones del navegador.

¿Los headers se ponen en el proxy o en la aplicación?

Preferiblemente en el borde. Configurarlos en Nginx, un CDN o un API gateway cubre todas las respuestas de forma uniforme, incluidas las páginas de error que tu app quizá no controla. El middleware a nivel de aplicación queda como respaldo para headers que dependen del contexto de la ruta, como los nonces de CSP.