12k
All articles

Cómo verificar la firma de un webhook

Verifica firmas de webhooks de Stripe y GitHub en Express con cuerpos sin procesar, HMAC en tiempo constante, protección contra repeticiones y rotación de secretos.

OpenReplay Team
OpenReplay Team
Cómo verificar la firma de un webhook

Para verificar la firma de un webhook, calcula un digest HMAC-SHA256 del cuerpo sin procesar (raw body) de la solicitud con tu secreto compartido. Después, compáralo en tiempo constante con la firma que el remitente incluyó en la cabecera de la solicitud y rechaza la solicitud si no coinciden.

Tu handler lleva semanas en producción. Entonces añades la comprobación de firmas y todas las entregas fallan con “invalid signature”, aunque el secreto es, sin lugar a dudas, el correcto.

Un endpoint de webhook es una URL pública y cualquiera puede enviarle un POST. Sin una comprobación de firma, tu handler confía en cualquier cosa que llegue. Si antes necesitas repasar el patrón, la guía de OpenReplay sobre webhooks cubre los conceptos básicos. En este artículo se construye un handler de Express correcto para Stripe y GitHub. Se abordan el orden del middleware de cuerpo sin procesar, la comparación en tiempo constante, la defensa contra ataques de repetición (replay) y una comprobación con dos secretos para la rotación, y se explica el motivo de cada paso.

Puntos clave

  • La firma de un webhook es un digest HMAC-SHA256 de los bytes exactos de la solicitud, por lo que cualquier parseo y reserialización del JSON antes de la comprobación hará que falle.
  • En Express, registra la ruta del webhook con express.raw({ type: 'application/json' }) antes de cualquier app.use(express.json()) global. El primer parser de cuerpo que se ejecute consume el stream de la solicitud.
  • crypto.timingSafeEqual lanza una excepción cuando sus dos buffers tienen longitudes distintas, así que compara primero las longitudes y trata una discrepancia como una firma no válida.
  • Stripe firma ${t}.${rawBody} y puede enviar una firma v1 por cada secreto activo. GitHub firma solo el cuerpo sin procesar y no incluye marca de tiempo, por lo que la deduplicación se hace mediante el ID de entrega.

Cómo funciona la verificación de firmas de webhooks

La firma de un webhook es un digest HMAC que el remitente calcula sobre el cuerpo sin procesar de la solicitud usando un secreto que solo conocen tú y el remitente. Un digest que coincide demuestra dos cosas: que el payload no se ha modificado y que proviene de alguien que posee ese secreto. HMAC incorpora el secreto en un hash SHA-256, de modo que sin el secreto nadie puede generar un digest válido para un cuerpo determinado. Tu servidor repite el cálculo y compara los resultados.

Los dos proveedores que se tratan aquí usan la misma primitiva, pero firman cadenas diferentes:

StripeGitHub
CabeceraStripe-Signature: t=<unix>,v1=<hex>X-Hub-Signature-256: sha256=<hex>
Cadena firmada${t}.${rawBody}cuerpo sin procesar
Codificaciónhexhex
Marca de tiempo firmadaSíNo
Defensa contra repeticiónRechazar t antiguosDeduplicar según X-GitHub-Delivery

Stripe documenta el formato de la cabecera y el payload firmado para la verificación manual. Los eventos de prueba también incluyen una firma v0 falsa, así que ignora cualquier esquema que no sea v1. GitHub describe su esquema en validación de entregas de webhooks: el valor siempre empieza por sha256=. GitHub sigue enviando la antigua cabecera SHA-1 X-Hub-Signature, pero la mantiene únicamente para que las integraciones antiguas sigan funcionando.

Lee el cuerpo sin procesar antes de cualquier parser de JSON

La causa más habitual de que la verificación de firmas de webhooks falle una y otra vez es un parser de JSON que se ejecuta antes de la comprobación. La verificación HMAC es exacta a nivel de byte: si reserializas el objeto parseado, cualquier cambio en los espacios en blanco, el orden de las claves o el escapado hará que el digest nunca coincida.

El orden del middleware es donde suele fallar todo. En Express, registra la ruta del webhook con express.raw() antes de cualquier app.use(express.json()) global. Una vez que el parser de JSON ha consumido el stream de la solicitud, los bytes originales desaparecen:

// Broken: the global JSON parser runs first, so req.body is a parsed object
app.use(express.json());
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);

// Fixed: webhook routes first, global JSON parser after
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);
app.use(express.json());

Añadir express.raw() a la ruta no arregla la versión defectuosa. En body-parser, el primer parser que se ejecuta lee el stream y marca la solicitud como ya parseada. Cualquier parser que se ejecute después omite la solicitud. Por lo tanto, el parser raw a nivel de ruta no recibe nada y req.body sigue siendo el objeto que produjo express.json().

Añade una comprobación Buffer.isBuffer(req.body) al handler. Así, un engañoso “signature mismatch” se convierte en un error de configuración evidente. El código de este artículo está pensado para Express 5. En Express 5, req.body es undefined cuando el content type no coincide con el parser, mientras que en Express 4 era {}. La comprobación devuelve 400 en ambos casos.

Si no puedes reordenar tu middleware, la opción verify de express.json() te proporciona el Buffer sin procesar antes del parseo, y puedes guardarlo en req para usarlo más adelante. En un route handler del App Router de Next.js no se ejecuta ningún parser de cuerpo antes de tu código, así que lee los bytes con Buffer.from(await request.arrayBuffer()), verifícalos y solo entonces llama a JSON.parse.

Calcula la firma esperada y compárala en tiempo constante

Calcula el digest con crypto.createHmac('sha256', secret) y pásale el Buffer directamente. Decodificar el cuerpo a una cadena y volver a codificarlo añade un paso adicional en el que los bytes pueden cambiar. Para Stripe, encadena .update(`${t}.`).update(rawBody) para que el cuerpo entre en el hash sin alteraciones.

Las firmas de webhooks deben compararse en tiempo constante, no con ===. Una comparación de cadenas puede terminar en el primer carácter distinto, por lo que su tiempo de ejecución revela qué parte del intento era correcta. crypto.timingSafeEqual(a, b) tarda lo mismo independientemente de dónde difieran los buffers. Además, lanza una excepción cuando los dos buffers tienen longitudes distintas. Comprueba primero las longitudes para que una cabecera mal formada cuente como firma no válida y no haga que el handler falle con un 500:

function safeEqualHex(expectedHex, receivedHex) {
  const a = Buffer.from(expectedHex, 'hex');
  const b = Buffer.from(receivedHex, 'hex');
  if (a.length === 0 || a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Cuando una firma falle de forma reiterada, revisa lo siguiente en este orden:

  1. req.body es un Buffer (registra Buffer.isBuffer(req.body) en los logs).
  2. El secreto corresponde exactamente a este endpoint y a este modo. Cada endpoint de Stripe tiene su propio secreto, y un endpoint que se usa tanto en modo de prueba como en modo live tiene un secreto distinto para cada uno.
  3. Si GitHub no envía ninguna cabecera X-Hub-Signature-256, el webhook no tiene ningún secreto configurado. Comprueba que estás leyendo la cabecera SHA-256 y no la antigua SHA-1, tal como se describe en la página de solución de problemas de GitHub.
  4. La cadena firmada es correcta: Stripe necesita el prefijo t. y hay que eliminar el prefijo sha256= de GitHub.
  5. Ambos lados están en hexadecimal. Pega el cuerpo sin procesar registrado y tu secreto en el generador HMAC de OpenReplay y compara manualmente su digest con la cabecera.

Rechaza las repeticiones con una marca de tiempo o un ID de entrega

Una firma de webhook válida demuestra quién envió una solicitud, pero no cuándo se envió, por lo que una solicitud firmada que se haya capturado una vez puede reenviarse más tarde. Con Stripe, rechaza cualquier entrega cuya marca de tiempo firmada tenga una antigüedad superior a unos cinco minutos. Eso coincide con la tolerancia predeterminada de las librerías de Stripe. Con GitHub, que no firma ninguna marca de tiempo, registra cada ID de entrega y omite los duplicados.

Comprueba la firma antes que la marca de tiempo. Hasta que no se haya verificado el HMAC que cubre t, t no es más que texto que un atacante puede fijar a cualquier valor. Trata también un t no numérico como un fallo: una comparación con NaN se evalúa como false y dejaría pasar silenciosamente una comprobación de vigencia ingenua.

La cabecera X-GitHub-Delivery de GitHub contiene un GUID para cada evento, y una reentrega conserva el mismo GUID. Registra el ID solo después de que el procesamiento se complete correctamente. GitHub no reintenta automáticamente las entregas fallidas y considera fallida una entrega si no recibe un 2xx en 10 segundos. Si registras el ID antes de una ejecución fallida, la reentrega manual que lances después se omitirá.

Acepta dos secretos durante la rotación

Durante la rotación del secreto del webhook, acepta una solicitud si cualquier secreto vigente produce un digest que coincida con cualquier valor v1 de la cabecera. Cuando rotas el secreto de un endpoint de Stripe, el secreto anterior puede seguir siendo válido durante el periodo que elijas, hasta un máximo de 24 horas. Hasta que caduque, cada entrega incluye una firma v1 independiente por cada secreto que siga siendo válido.

Un parser de la cabecera de Stripe que la convierta en un objeto conserva solo un v1 y rechaza entregas válidas, así que recoge todos los valores en un array. Para GitHub, prueba cada secreto configurado con la única cabecera. Los bucles some() terminan antes de tiempo cuando encuentran una coincidencia, y eso no filtra nada útil: cada comparación individual sigue siendo en tiempo constante.

El handler completo de Express

Este handler ESM combina todos los pasos. Devuelve 400 ante cualquier fallo de firma o de formato (los remitentes solo distinguen entre 2xx y no 2xx) y 200 para los duplicados de GitHub, que se confirman y se omiten:

import express from 'express';
import crypto from 'node:crypto';

const app = express();
const TOLERANCE_SECONDS = 300;

// Current secret first, previous secret during rotation
const STRIPE_SECRETS = [
  process.env.STRIPE_WEBHOOK_SECRET,
  process.env.STRIPE_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);
const GITHUB_SECRETS = [
  process.env.GITHUB_WEBHOOK_SECRET,
  process.env.GITHUB_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);

if (STRIPE_SECRETS.length === 0 || GITHUB_SECRETS.length === 0) {
  throw new Error('Webhook secrets are not configured');
}

function safeEqualHex(expectedHex, receivedHex) {
  const a = Buffer.from(expectedHex, 'hex');
  const b = Buffer.from(receivedHex, 'hex');
  if (a.length === 0 || a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

function parseStripeHeader(header) {
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(',')) {
    const i = part.indexOf('=');
    if (i === -1) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't') timestamp = value;
    else if (key === 'v1') signatures.push(value); // keep every v1
  }
  return { timestamp, signatures };
}

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('Stripe-Signature');
  if (!header || !Buffer.isBuffer(req.body)) {
    return res.status(400).send('Missing signature or raw body');
  }

  const { timestamp, signatures } = parseStripeHeader(header);
  if (!timestamp || signatures.length === 0) {
    return res.status(400).send('Malformed signature header');
  }

  const valid = STRIPE_SECRETS.some((secret) => {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.`)
      .update(req.body)
      .digest('hex');
    return signatures.some((sig) => safeEqualHex(expected, sig));
  });
  if (!valid) return res.status(400).send('Invalid signature');

  // Only trust t after the HMAC over it has been verified
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (!Number.isFinite(age) || Math.abs(age) > TOLERANCE_SECONDS) {
    return res.status(400).send('Timestamp outside tolerance');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Hand event off to a queue; respond fast
  res.sendStatus(200);
});

const seenDeliveries = new Set(); // Use a shared store with a TTL in production

app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-Hub-Signature-256');
  if (!header?.startsWith('sha256=') || !Buffer.isBuffer(req.body)) {
    return res.status(400).send('Missing signature or raw body');
  }
  const received = header.slice('sha256='.length);

  const valid = GITHUB_SECRETS.some((secret) =>
    safeEqualHex(
      crypto.createHmac('sha256', secret).update(req.body).digest('hex'),
      received,
    ),
  );
  if (!valid) return res.status(400).send('Invalid signature');

  const deliveryId = req.get('X-GitHub-Delivery');
  if (!deliveryId) return res.status(400).send('Missing delivery ID');
  if (seenDeliveries.has(deliveryId)) return res.sendStatus(200);

  const payload = JSON.parse(req.body.toString('utf8'));
  // Process payload, then record the ID only after success
  seenDeliveries.add(deliveryId);
  res.sendStatus(200);
});

app.use(express.json()); // Everything else, after the webhook routes

app.listen(3000);

Cada comprobación cubre un fallo concreto. Una cabecera ausente devuelve 400 y no lanza ninguna excepción. Se conservan todos los valores v1. Las comprobaciones de longitud se ejecutan antes de timingSafeEqual. Un cuerpo que no es un Buffer se detecta como error de configuración. En aplicaciones que solo usan Stripe, el método stripe.webhooks.constructEvent de la librería oficial realiza estas mismas comprobaciones por ti, y la documentación de webhooks de Stripe muestra cómo llamarlo.

Conclusión

La verificación de firmas de webhooks se reduce a calcular el hash de los bytes exactos que llegaron y comparar el resultado de forma segura. El resto del handler existe para mantener intactos esos bytes, frenar las repeticiones y resistir la rotación de secretos. Primero, mueve tus rutas de webhook por encima de express.json() y, después, añade el handler anterior. Si todavía estás decidiendo si la entrega push encaja con tu integración, en webhooks vs. polling se comparan las ventajas y desventajas de cada enfoque.

Preguntas frecuentes

¿Por qué falla la verificación de firmas de Stripe al probar en local con la Stripe CLI?

La Stripe CLI usa su propio secreto de firma, que es distinto del secreto de cualquier endpoint creado en el Dashboard de Stripe. Ambos empiezan por whsec_, por lo que es fácil confundirlos. Cuando ejecutas stripe listen, la CLI muestra su secreto en la terminal. Usa ese valor para los eventos que reenvía la CLI y nunca verifiques eventos reenviados por la CLI con el secreto de un endpoint del Dashboard, ni al revés.

¿Debo usar constructEvent de Stripe o verificar la firma manualmente?

Usa stripe.webhooks.constructEvent si Stripe es tu único proveedor. Le pasas el cuerpo sin procesar de la solicitud, el valor de la cabecera Stripe-Signature y el secreto de tu endpoint, y lanza un error cuando la firma no es válida. De forma predeterminada, las librerías de Stripe rechazan una marca de tiempo firmada que difiera más de 5 minutos de la hora actual, y un argumento opcional permite definir un margen distinto. La verificación manual tiene sentido cuando GitHub u otros proveedores comparten la misma ruta de código.

¿Por qué usar HMAC en lugar de calcular con SHA-256 el hash del secreto y el cuerpo juntos?

HMAC resiste los ataques de extensión de longitud (length-extension attacks), que rompen una construcción ingenua como SHA-256 del secreto seguido del cuerpo. SHA-256 se basa en el diseño Merkle-Damgard, por lo que cualquiera que tenga un digest válido puede añadir datos al mensaje y calcular un digest válido para el mensaje más largo sin conocer el secreto. HMAC procesa la clave en dos pasadas de hash anidadas, lo que impide este ataque. Tanto Stripe como GitHub firman con HMAC-SHA256.

¿Sigo necesitando HTTPS si verifico las firmas de los webhooks?

Sí. Una firma HMAC protege la integridad y la autenticidad, pero no la confidencialidad. Sin TLS, cualquiera que esté en la ruta de red puede leer el payload, que puede contener datos de clientes o de pagos, y capturar una solicitud firmada para reenviarla más tarde. Las comprobaciones de firma, las defensas contra repeticiones y HTTPS cubren amenazas distintas, así que un endpoint de webhook en producción necesita las tres.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.