StackPractices
intermediate Por Mathias Paulenko

Configura headers de seguridad HTTP con Helmet en Node.js

Establece headers de seguridad HTTP en apps Express con Helmet: CSP, HSTS, X-Frame-Options, X-Content-Type-Options y CORS para seguridad web OWASP

Visión General

Cada respuesta que envía tu app Express lleva headers que protegen a los usuarios o los dejan expuestos: clickjacking, XSS y MIME sniffing explotan precisamente los que faltan. Helmet es un paquete de ~15 middleware pequeños que configura esos headers por ti, y app.use(helmet()) activa valores seguros por defecto con una línea. Esta receta cubre la configuración de Helmet 8 con Content Security Policy (CSP), HSTS, CORS y overrides por ruta para apps que cumplen OWASP.

Cuándo Usar

Usa este recurso cuando:

  • Ejecutes cualquier aplicación web o API en Express.js en producción
  • Despliegues que deban pasar auditorías de headers de seguridad OWASP (securityheaders.com, pentests)
  • Apps que sirven HTML a navegadores, donde CSP es tu principal mitigación de XSS
  • Al endurecer una API que igualmente se beneficia de nosniff, HSTS y quitar X-Powered-By

Helmet solo funciona con Express y frameworks estilo Connect. Para Fastify usa @fastify/helmet, para Koa usa koa-helmet, y en Next.js configura los headers en next.config.js: el middleware no encaja en esos setups.

Requisitos Previos

  • Node.js 18+
  • Proyecto Express.js (npm install express)
  • Helmet 8 (npm install helmet@8) — las opciones de abajo usan la nomenclatura x* de v8; los nombres antiguos de v6 (frameguard, noSniff) siguen funcionando como aliases deprecated

Solución

Los snippets están listos para copiar y pegar; una app Express ejecutable con tests está en el repositorio complementario.

1. Instalar dependencias

npm install express helmet@8 cors

2. Configuración básica de Helmet

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

const app = express();

// Habilitar Helmet con headers de seguridad por defecto
app.use(helmet());

// Habilitar CORS con orígenes específicos
app.use(cors({
  origin: ["https://example.com", "https://app.example.com"],
  methods: ["GET", "POST", "PUT", "DELETE"],
  allowedHeaders: ["Content-Type", "Authorization"],
  credentials: true,
  maxAge: 86400, // Cache preflight: 24 horas
}));

app.get("/", (req, res) => {
  res.json({ message: "Secure API" });
});

app.listen(3000, () => console.log("Server running on port 3000"));

3. Content Security Policy (CSP) personalizado

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: [
        "'self'",
        "https://cdn.jsdelivr.net",
        "https://www.googletagmanager.com",
      ],
      styleSrc: [
        "'self'",
        "https://fonts.googleapis.com",
        "'unsafe-inline'", // Requerido para muchos frameworks CSS
      ],
      fontSrc: [
        "'self'",
        "https://fonts.gstatic.com",
      ],
      imgSrc: [
        "'self'",
        "data:",
        "https:",
      ],
      connectSrc: [
        "'self'",
        "https://api.example.com",
        "https://www.google-analytics.com",
      ],
      frameAncestors: ["'none'"], // Prevenir clickjacking
      baseUri: ["'self'"],
      formAction: ["'self'"],
      objectSrc: ["'none'"],
      upgradeInsecureRequests: [],
    },
  },
}));

4. HSTS (HTTP Strict Transport Security)

app.use(helmet({
  strictTransportSecurity: {
    maxAge: 31536000, // 1 año en segundos
    includeSubDomains: true,
    preload: true,
  },
}));

5. Control fino de headers

app.use(helmet({
  xFrameOptions: { action: "deny" },        // clickjacking (nombre v6: frameguard)
  xContentTypeOptions: true,                // nosniff — ya es el default
  xPoweredBy: true,                         // quitar X-Powered-By (default)
  referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  xDnsPrefetchControl: { allow: false },    // X-DNS-Prefetch-Control (v6: dnsPrefetchControl)
  xDownloadOptions: true,                   // X-Download-Options: noopen (v6: ieNoOpen)
  xPermittedCrossDomainPolicies: { permittedPolicies: "none" },
  crossOriginEmbedderPolicy: { policy: "require-corp" },
  crossOriginOpenerPolicy: { policy: "same-origin" },
  crossOriginResourcePolicy: { policy: "same-origin" },
  originAgentCluster: true,
}));

6. Configuración de headers por ruta

// Rutas API — headers más estrictos, sin CSP
const apiHelmet = helmet({
  contentSecurityPolicy: false,
  xFrameOptions: { action: "deny" },
});

app.use("/api", apiHelmet);

// Rutas web — CSP completo
const webHelmet = helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "https://cdn.jsdelivr.net"],
    },
  },
});

app.use("/web", webHelmet);

7. Middleware de headers personalizados

// Headers de seguridad personalizados no cubiertos por Helmet
app.use((req, res, next) => {
  // Permissions Policy (sucesor de Feature Policy)
  res.setHeader(
    "Permissions-Policy",
    "camera=(), microphone=(), geolocation=(), payment=()"
  );

  // X-Robots-Tag (prevenir indexación de endpoints API)
  if (req.path.startsWith("/api")) {
    res.setHeader("X-Robots-Tag", "noindex, nofollow");
  }

  // Cache-Control para endpoints sensibles
  if (req.path.includes("/auth") || req.path.includes("/user")) {
    res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
  }

  next();
});

8. Verificar headers con test

// Instalar: npm install --save-dev jest supertest
// Exporta tu app (module.exports = app) antes de requerirla aquí.
const request = require("supertest");
const app = require("./app");

describe("Security Headers", () => {
  it("should set X-Content-Type-Options", async () => {
    const response = await request(app).get("/");
    expect(response.headers["x-content-type-options"]).toBe("nosniff");
  });

  it("should set X-Frame-Options", async () => {
    const response = await request(app).get("/");
    expect(response.headers["x-frame-options"]).toBe("DENY");
  });

  it("should set Strict-Transport-Security", async () => {
    const response = await request(app).get("/");
    expect(response.headers["strict-transport-security"]).toContain("max-age=31536000");
  });

  it("should set Content-Security-Policy", async () => {
    const response = await request(app).get("/");
    expect(response.headers["content-security-policy"]).toContain("default-src 'self'");
  });

  it("should remove X-Powered-By", async () => {
    const response = await request(app).get("/");
    expect(response.headers["x-powered-by"]).toBeUndefined();
  });
});

Cómo Funciona

helmet() no es un solo middleware: es un paquete de ~15 pequeños, cada uno responsable de un header. Llamarlo sin argumentos activa los defaults de la tabla; pasar un objeto de opciones sobreescribe cada middleware de forma independiente.

Pipeline de una petición con Helmet: la petición entrante pasa por el middleware helmet que establece los headers de seguridad, luego llega a tus rutas, y la respuesta sale con los headers configurados

Lo que helmet() configura por defecto (defaults de v8):

HeaderValor por defectoQué bloquea
Content-Security-Policydefault-src 'self' …Fuentes XSS inline/externas
Strict-Transport-Securitymax-age=15552000 (180 días)Downgrade a HTTP, MITM
X-Frame-OptionsSAMEORIGINClickjacking vía iframes
X-Content-Type-OptionsnosniffAtaques de MIME-sniffing
Referrer-Policystrict-origin-when-cross-originFuga de referrer
Cross-Origin-Embedder-Policyrequire-corpEmbebido de recursos no autorizado
Cross-Origin-Opener-Policysame-originAtaques al window handle
Cross-Origin-Resource-Policysame-originLectura cross-origin de recursos
X-DNS-Prefetch-ControloffDNS prefetch no deseado
X-Download-OptionsnoopenBug de ejecución de archivos en IE8
X-Permitted-Cross-Domain-PoliciesnoneLecturas cross-domain de Flash/PDF
X-Powered-ByeliminadoFingerprinting del stack
X-XSS-Protection0Quirks del auditor legado

Tres headers merecen cuidado extra:

  • CSP hace el trabajo pesado: define desde dónde pueden cargarse scripts, estilos e imágenes, que es lo que realmente detiene el código inyectado. Un default-src 'self' estricto también bloquea CDNs legítimos — por eso las configs de producción casi siempre necesitan entradas explícitas en scriptSrc/styleSrc, y por eso existe reportOnly.
  • HSTS es un trinquete de una sola dirección: cuando un navegador ve max-age, rechaza HTTP plano hacia ese dominio hasta que expire — y includeSubDomains lo aplica también a staging. e internal.. preload va más allá y mete tu dominio en los navegadores de forma permanente; actívalo solo cuando HTTPS sea definitivamente permanente.
  • frame-ancestors en CSP vs X-Frame-Options: los navegadores modernos prefieren la directiva CSP e ignoran XFO cuando ambos existen. Helmet establece los dos porque el header sigue protegiendo a usuarios en motores antiguos — mantenlos, y configura frame-ancestors con tu intención real.

Variantes

CSP basado en nonce para scripts inline

const crypto = require("crypto");

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString("base64");
  next();
});

app.use(helmet({
  contentSecurityPolicy: {
    useDefaults: true,
    directives: {
      scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
    },
  },
}));

// Usar nonce en templates
app.get("/", (req, res) => {
  res.send(`
    <script nonce="${res.locals.nonce}">
      console.log("This inline script is allowed");
    </script>
  `);
});

CSP report-only (testing)

// Reportar violaciones sin bloquear — para testear CSP antes de aplicar
app.use(helmet({
  contentSecurityPolicy: {
    reportOnly: true,
    directives: {
      defaultSrc: ["'self'"],
      reportUri: ["/api/csp-report"],
    },
  },
}));

app.post("/api/csp-report", express.json({ type: "application/csp-report" }), (req, res) => {
  console.log("CSP Violation:", req.body);
  res.status(204).end();
});

ES Modules (ESM)

Todo lo anterior usa CommonJS porque Express 4 es el default. En proyectos con "type": "module" la configuración es idéntica con sintaxis import — Helmet 8 distribuye build dual:

import express from "express";
import helmet from "helmet";
import cors from "cors";

const app = express();
app.use(helmet());

Producción vs desarrollo

const isProduction = process.env.NODE_ENV === "production";

app.use(helmet({
  contentSecurityPolicy: isProduction ? {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'"],
    },
  } : false, // Deshabilitar CSP en dev para hot reload

  strictTransportSecurity: isProduction ? {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true,
  } : false, // Deshabilitar HSTS en dev (HTTP)
}));

Verificar tus Headers

Antes de desplegar, confirma lo que el servidor realmente envía. La comprobación más rápida: curl -I https://tuapp.com y busca los headers que configuraste.

curl -sI https://tuapp.com | grep -iE "strict-transport|x-frame|x-content|content-security"

Deberías ver algo así:

strict-transport-security: max-age=31536000; includeSubDomains; preload
x-frame-options: DENY
x-content-type-options: nosniff
content-security-policy: default-src 'self';script-src 'self' https://cdn.jsdelivr.net

Para una auditoría completa, pega tu URL en securityheaders.com: puntúa tu configuración y marca los headers que faltan. En DevTools del navegador, la pestaña Network muestra los headers de respuesta por petición, y la consola reporta violaciones CSP con la directiva exacta que se disparó.

Mejores Prácticas

  • Para una baseline de seguridad más amplia, mira la guía API Security Checklist — Authentication to Encryption.
  • Empieza con CSP report-only: ejecuta reportOnly: true unos días para recolectar violaciones antes de aplicar, o romperás assets en producción
  • Usa nonces para scripts inline: 'unsafe-inline' en scriptSrc neutraliza la protección XSS; los nonces por petición la mantienen estricta
  • Establece HSTS solo sobre HTTPS: los navegadores ignoran HSTS en respuestas HTTP, y cachearlo por accidente en dev te deja fuera de localhost
  • Fija la versión de helmet en package.json: v8 renombró varias opciones, así que un upgrade mayor silencioso puede cambiar qué headers se establecen

Errores Comunes

  • Usar 'unsafe-inline' en scriptSrc: permite cualquier script inline, deshaciendo la mayor parte de lo que protege CSP — usa nonces o hashes en su lugar
  • Olvidar deshabilitar HSTS en desarrollo: los navegadores cachean HSTS por dominio, así que localhost de repente exige HTTPS en todos los navegadores donde probaste
  • Usar nombres de opciones pre-v8 a ciegas: frameguard y compañía siguen funcionando como aliases deprecated, pero contentTypeOptions no es una opción real — se ignora silenciosamente y nosniff solo aplica porque es el default
  • No establecer frame-ancestors en CSP: X-Frame-Options por sí solo es el mecanismo legado; los navegadores modernos siguen frame-ancestors
  • CORS demasiado permisivo: origin: "*" combinado con credentials: true es rechazado por los navegadores e inseguro de todos modos — usa una lista blanca de orígenes reales

Lectura Adicional

Preguntas frecuentes

¿Helmet hace mi app segura?

Hace seguros tus headers — es una capa. Helmet no te salva de inyección SQL, auth rota o input sin sanitizar; combínalo con validación de entrada, autenticación y rate limiting.

¿Debo usar Helmet para backends solo API?

Sí, pero recórtalo. nosniff, quitar X-Powered-By y HSTS siguen importando para APIs, mientras que CSP solo ayuda cuando sirves HTML — por eso el ejemplo por ruta deshabilita contentSecurityPolicy en /api.

¿Cómo depuro violaciones de CSP?

Activa reportOnly: true y loguea lo que vuelve — el reporte de violación nombra la directiva y la URI bloqueadas. En DevTools, los errores CSP aparecen en la consola como "Refused to load … because it violates the following Content Security Policy directive".

¿Puedo usar Helmet con Next.js?

Next.js no ejecuta middleware de Express. Configura los mismos headers en next.config.js con la función headers() — los valores de esta receta se trasladan directamente.

¿Qué versión de Helmet debo instalar?

Helmet 8. npm install helmet actualmente resuelve a v8, que renombró varias opciones a la nomenclatura x* (frameguard → xFrameOptions, noSniff → xContentTypeOptions). Los nombres antiguos siguen funcionando como aliases deprecated, pero el código nuevo debería usar los nombres de v8 que se muestran aquí.

¿Puedo establecer estos headers manualmente sin Helmet?

res.setHeader() funciona para un puñado de headers, pero reimplementarías a mano la serialización de directivas CSP, los defaults y el control por ruta. Helmet existe porque esa superficie es fácil de equivocar sutilmente.

¿Por qué se rompió mi sitio al activar CSP?

Casi siempre por 'unsafe-inline' o una entrada faltante en la lista blanca: los scripts inline, fuentes externas, analytics y assets de CDN quedan bloqueados por default-src 'self' hasta que los permitas. Compara los fallos de la pestaña Network con las listas scriptSrc/styleSrc/fontSrc/imgSrc, o ejecuta primero el modo report-only.

¿Helmet funciona con Fastify o Koa?

No directamente — es middleware de Express. Fastify tiene @fastify/helmet (mismo objeto de opciones) y Koa tiene koa-helmet (superficie de API más antigua). Los conceptos de headers son idénticos; solo cambia la integración.

¿Necesito X-Frame-Options si ya configuro frame-ancestors?

Mantén ambos. Los navegadores modernos prefieren frame-ancestors de CSP e ignoran XFO cuando está presente, pero el header sigue protegiendo a usuarios en motores antiguos — y Helmet establece los dos de todos modos.

¿Qué hace realmente el flag preload de HSTS?

Establecer preload por sí solo no cambia nada; marca tu header HSTS como elegible para envío a la lista de preload de los navegadores. Una vez aceptado, los navegadores traen tu dominio hardcodeado como solo-HTTPS — nunca ocurre una primera petición HTTP. La eliminación tarda meses, así que trátalo como permanente.