Todos los artículos

Seguridad · 25 de julio de 2026 · 11 min de lectura

Seguridad de webhooks: firma HMAC, replay attacks y buenas prácticas

Un endpoint de webhook es, por definición, una URL pública que acepta POST desde cualquier lugar de internet. Sin protección, cualquiera que descubra (o adivine) esa dirección puede falsificar un "pago aprobado" y hacer que tu sistema entregue producto gratis. La buena noticia: la defensa es bien conocida — firma HMAC, protección contra replay y un puñado de prácticas que proveedores como Stripe y GitHub aplican desde hace años. Esta guía explica cada capa y muestra el código.

Por qué tu endpoint es una superficie de ataque

A diferencia del resto de tu API, el endpoint de webhook no está detrás de un login: el proveedor necesita alcanzarlo sin sesión, sin cookie, sin OAuth. Es una puerta que está siempre abierta. Y lo que llega por ella suele disparar acciones valiosas — marcar una factura como pagada, habilitar acceso, iniciar una entrega.

El ataque más obvio ni siquiera exige sofisticación. Si tu sistema confía en cualquier JSON que llegue con "type": "payment.approved", basta un curl bien armado para defraudarlo:

un POST falsificado es trivial
curl -X POST https://tu-app.com/webhooks/pagos \
  -H "Content-Type: application/json" \
  -d '{"type":"payment.approved","data":{"payment_id":"pay_falso","amount":14990}}'

La pregunta que la seguridad de webhooks responde es: ¿cómo saber que la solicitud vino realmente del proveedor y no fue alterada en el camino? La respuesta estándar de la industria es la firma HMAC.

Firma HMAC: cómo funciona

HMAC (Hash-based Message Authentication Code) combina una función de hash — casi siempre SHA-256 — con un secreto compartido entre el proveedor y tú. El flujo:

  1. Al registrar el webhook, el proveedor genera (o tú defines) un secreto, por ejemplo whsec_a1b2c3…. Queda guardado en ambos lados y nunca viaja con las entregas.
  2. Antes de enviar cada evento, el proveedor calcula HMAC-SHA256(secreto, cuerpo) sobre los bytes exactos del payload y envía el resultado en un header — algo como X-Hub-Signature-256 o Stripe-Signature.
  3. Al recibir, rehaces el mismo cálculo con tu secreto y comparas. Si coincide, el mensaje es auténtico e íntegro; si no, descártalo.

Como solo quien conoce el secreto puede producir una firma válida, un atacante puede conocer tu URL y el formato del payload — sin el secreto, ningún POST falsificado pasa. Y como el hash cubre el cuerpo entero, cualquier byte alterado en tránsito también invalida la firma.

Validando en Node.js

server.js · validación de firma HMAC
import crypto from "node:crypto";
import express from "express";

const app = express();

// El cuerpo DEBE llegar crudo: usa express.raw, no express.json,
// en esta ruta — la firma se calculó sobre los bytes originales.
app.post(
  "/webhooks/pagos",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    const received = req.get("x-signature-sha256") ?? "";

    const expected = crypto
      .createHmac("sha256", secret)
      .update(req.body) // Buffer con el cuerpo crudo
      .digest("hex");

    const a = Buffer.from(received);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).end(); // sin detalles del motivo
    }

    const event = JSON.parse(req.body.toString("utf8"));
    // encola el procesamiento y responde rápido
    res.status(200).end();
  },
);

Dos detalles de este código rompen más integraciones que cualquier otro:

  • Usa el cuerpo crudo (raw body). Si un middleware parsea el JSON y recalculas el HMAC sobre JSON.stringify(req.body), la validación va a fallar de forma intermitente: la re-serialización puede cambiar el orden de las claves, los escapes unicode y los espacios — bytes distintos, hash distinto. La firma siempre se verifica sobre los bytes exactamente como llegaron.
  • Compara con timingSafeEqual. Una comparación común (===) retorna más rápido cuanto antes divergen los textos, y ese tiempo de respuesta filtra información que permite reconstruir la firma byte a byte — el llamado timing attack. La comparación en tiempo constante elimina ese canal.

Los headers donde cada proveedor coloca esta firma — y los demás headers que acompañan una entrega — están diseccionados en Anatomía de una solicitud de webhook.

Replay attacks: la firma sola no alcanza

Supón que un atacante logra capturar una entrega legítima completa — headers y cuerpo — por un log expuesto, un proxy mal configurado o un endpoint de inspección olvidado. No conoce el secreto, pero no lo necesita: la firma de ese mensaje específico ya está lista. Le basta reenviar la misma solicitud, intacta, cuantas veces quiera. Eso es un replay attack — y la validación HMAC pura acepta todas las copias.

La defensa estándar es incluir un timestamp en el material firmado y rechazar mensajes viejos. Es exactamente el diseño del webhook de Stripe: el header Stripe-Signature trae t= (timestamp) y v1= (firma), y el HMAC se calcula sobre {timestamp}.{cuerpo}. Como el timestamp participa del hash, el atacante no puede "renovar" un mensaje viejo sin romper la firma.

validación con ventana de tolerancia (estilo Stripe)
const TOLERANCE_SECONDS = 5 * 60; // 5 minutos

function verifySignedWebhook(header, rawBody, secret) {
  // header: "t=1784990400,v1=5257a869e7ecebeda32affa62cdca3fa..."
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=")),
  );

  const ageSeconds = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (ageSeconds > TOLERANCE_SECONDS) return false; // mensaje viejo: ¿replay?

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(parts.v1 ?? "");
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

La ventana de tolerancia (5 minutos es el estándar de Stripe) existe para acomodar relojes levemente desincronizados y demoras de red. Dentro de la ventana, la idempotencia completa la defensa: si registras el id de cada evento procesado e ignoras repeticiones, un replay dentro de la ventana se convierte en un no-op inofensivo.

Cómo firman los grandes proveedores

El mecanismo es el mismo en todas partes; cambian el header, la codificación y la presencia de timestamp:

ProveedorHeaderFormato¿Timestamp?
StripeStripe-Signaturet=…,v1=… — HMAC-SHA256 hex sobre t.cuerpoSí, en la firma
GitHubX-Hub-Signature-256sha256=… — HMAC-SHA256 hex del cuerpoNo
ShopifyX-Shopify-Hmac-Sha256HMAC-SHA256 del cuerpo, codificado en base64No
Mercado Pagox-signaturets=…,v1=… — HMAC-SHA256 hex sobre un manifest con id y request-idSí, en la firma

Antes de implementar, lee la especificación de tu proveedor — los detalles (qué entra en el material firmado, hex vs. base64) varían y cualquier divergencia hace fallar la validación. La documentación de GitHub sobre validación de entregas es un buen ejemplo de referencia bien escrita, con vectores de prueba para verificar tu implementación.

Buenas prácticas complementarias

La firma es la base, pero una postura seria de seguridad suma otras capas:

  • HTTPS, siempre. Sin TLS, el payload y la firma viajan legibles — cualquier intermediario captura el par y obtiene material de replay. Los proveedores serios ni siquiera aceptan registrar una URL http://.
  • Un secreto por endpoint, con rotación. Un secreto compartido entre ambientes (producción, staging, dev) multiplica los puntos de fuga. Genera uno por endpoint y cámbialo periódicamente — proveedores como Stripe soportan dos secretos activos durante la rotación, para cambiar sin perder entregas.
  • Allowlist de IPs — como capa extra, nunca única. Algunos proveedores publican sus rangos de IP de origen. Filtrar por ellos reduce ruido, pero las IPs cambian sin aviso, la lista envejece y el origen de red es falsificable en escenarios de infraestructura comprometida. Trátalo como defensa en profundidad, no como sustituto de la firma.
  • ¿Falló la validación? 401 y silencio. Responde 401 (o 400) sin cuerpo que explique el motivo. Mensajes como "firma esperada: X" son un oráculo de depuración para el atacante.
  • No registres payload sensible en los logs. Los webhooks de pago llevan e-mail, documento, montos. Logs con el payload completo se convierten en la fuga que alimenta el replay y la ingeniería social. Registra metadatos (id del evento, tipo, resultado de la validación) y enmascara el resto.
  • Confirma en la API antes de liberar valor. Incluso con firma válida, el flujo robusto trata el webhook como notificación y consulta la API del proveedor para confirmar el estado antes de cualquier acción irreversible — habilitar acceso, dar por pagado, reembolsar.

Vale decirlo: la mayoría de los incidentes reales no viene de ataques elaborados, sino de atajos — validación apagada "solo en staging", secreto commiteado en el repositorio, endpoint de prueba olvidado en producción. Esos y otros tropiezos están en Los 10 errores más comunes al implementar webhooks.

Checklist de seguridad

  • El endpoint atiende solamente HTTPS.
  • Firma HMAC validada sobre el cuerpo crudo, en toda solicitud, en todo ambiente.
  • Comparación en tiempo constante (timingSafeEqual o equivalente).
  • Timestamp verificado con ventana de tolerancia (cuando el proveedor firma con timestamp).
  • Deduplicación por id del evento (idempotencia) cubriendo replays dentro de la ventana.
  • Un secreto por endpoint, fuera del código, con plan de rotación.
  • Respuesta 401 sin detalles cuando la validación falla.
  • Logs sin payload sensible; acciones críticas confirmadas vía API.

Resumen

  • Un endpoint de webhook es una URL pública: sin validación, cualquier POST falsificado se convierte en "pago aprobado".
  • HMAC-SHA256 con secreto compartido autentica origen e integridad — siempre sobre el cuerpo crudo, siempre con comparación en tiempo constante.
  • El replay se combate con timestamp dentro del material firmado + ventana de tolerancia + idempotencia por id de evento.
  • HTTPS, secreto por endpoint con rotación, respuestas sin detalles y logs limpios completan las capas; la allowlist de IP es extra, no fundación.
  • Un webhook autenticado sigue siendo una notificación: confirma en la API antes de liberar valor.