Blog · Ingeniería

Verificar webhooks firmados con HMAC-SHA256 sin errores de seguridad

Cómo comprobar la firma HMAC-SHA256 de un webhook sobre el cuerpo crudo, en tiempo constante y con margen de tiempo, con código para Node.js y Web Crypto.

Read in English

Verificar un webhook firmado con HMAC-SHA256 consiste en recalcular la firma con tu secreto sobre el cuerpo exacto que llegó, compararla en tiempo constante con la que trae la cabecera y rechazar las llamadas con una marca de tiempo antigua. Casi todos los fallos de seguridad, y casi todos los «la firma nunca cuadra», vienen de saltarse uno de esos tres pasos. Los repasamos con código para Node.js y Web Crypto.

En resumen:

  • Verifica sobre el cuerpo crudo, antes de parsear el JSON: un JSON vuelto a serializar casi nunca produce los mismos bytes.
  • Compara con crypto.timingSafeEqual o crypto.subtle.verify, nunca con ===.
  • Rechaza marcas de tiempo de más de 5 minutos y descarta los eventos repetidos por su id.
  • Usa el secreto tal cual te lo dan, guárdalo en una variable de entorno y mantenlo fuera de los logs.
  • Responde 2xx enseguida y deja el trabajo lento para después.

Cómo funciona la firma HMAC de un webhook

HMAC, definido en el RFC 2104, combina una función hash (aquí SHA-256) con una clave secreta que solo conocen el emisor y tú. Sin la clave no se puede generar una firma válida para un cuerpo dado, y cambiar un solo byte del cuerpo produce una firma completamente distinta. HTTPS protege el transporte, pero no impide que cualquiera haga un POST a tu URL: la firma es lo que demuestra quién envía la llamada y que nadie la ha tocado.

Un formato habitual mete la marca de tiempo dentro de lo firmado. Así llegan, por ejemplo, los webhooks de castly:

Castly-Signature: t=1763049608,v1=5f2b…c9e1

El emisor calcula HMAC-SHA256 con el secreto sobre 1763049608. seguido del cuerpo, y envía el resultado en hexadecimal en v1. Como t forma parte del mensaje firmado, nadie puede cambiar la hora de una llamada capturada sin invalidar la firma. Tu trabajo es repetir exactamente esa cuenta y comparar.

Error 1: verificar el JSON ya parseado

El fallo más común es dejar que el framework parsee el JSON y después verificar sobre JSON.stringify(req.body). El resultado puede llevar otros espacios, otro orden de claves o caracteres escapados de otra forma (é frente a é), y basta un byte distinto para que la firma no coincida. La «solución» que se ve demasiado a menudo es desactivar la verificación, que es justo lo que no hay que hacer.

La regla: lee el cuerpo como texto o bytes, verifica, y solo entonces haz JSON.parse. Según tu entorno:

  • Express: usa express.raw({ type: 'application/json' }) en la ruta del webhook y evita que un express.json() global se ejecute antes.
  • Next.js (App Router), Cloudflare Workers y Hono: await request.text() (en Hono, await c.req.text()).
  • Detrás de un proxy: comprueba que ningún middleware descomprime, recodifica o recorta el cuerpo antes de que llegue a tu código.

Error 2: comparar firmas con ===

Una comparación normal de cadenas se detiene en el primer carácter distinto. Midiendo cuánto tarda tu servidor en rechazar firmas falsas, un atacante podría ir acertando la firma correcta byte a byte. Sobre una red real es un ataque difícil, pero la defensa cuesta una línea:

  • Node.js: crypto.timingSafeEqual(a, b) compara dos Buffer en tiempo constante. Lanza una excepción si tienen distinta longitud, así que valida antes el formato: una firma SHA-256 en hexadecimal tiene exactamente 64 caracteres.
  • Web Crypto (Workers, Deno, Bun): importa la clave con el uso verify y deja la comparación a crypto.subtle.verify(), en lugar de calcular la firma y compararla tú.
const enc = new TextEncoder();
const clave = await crypto.subtle.importKey(
  'raw', enc.encode(secreto), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'],
);
const firma = Uint8Array.from(v1.match(/../g), (h) => parseInt(h, 16));
const valida = await crypto.subtle.verify('HMAC', clave, firma, enc.encode(`${t}.${cuerpo}`));

Si despliegas en el edge, en web push desde Cloudflare Workers tienes el resto del flujo.

Error 3: aceptar firmas antiguas

Una firma válida lo es para siempre. Si alguien captura una llamada (un log mal protegido, un proxy de depuración), puede reenviarla días después y tu servidor la aceptará. El timestamp firmado lo impide: rechaza cualquier t que se aleje más de 5 minutos de tu reloj, en los dos sentidos.

  • Valida que t es un número. En JavaScript, Math.abs(ahora - Number('abc')) > 300 es false, porque cualquier comparación con NaN lo es: un t basura pasaría ese filtro.
  • Sincroniza el reloj con NTP. Un desfase de varios minutos te hará rechazar llamadas legítimas.
  • Descarta repetidos. Dentro de la ventana, la misma llamada puede llegar dos veces. Guarda el id de cada evento procesado un poco más que la ventana y descarta los duplicados; es la misma idea que la idempotencia al enviar notificaciones.

Error 4: manejar mal el secreto

  • Úsalo exactamente como te lo dan. Hay proveedores que esperan que decodifiques una parte en base64; en castly, la clave HMAC es el whsec_… completo, prefijo incluido. Si quitas el prefijo, ninguna firma cuadrará.
  • Guárdalo como secreto, en una variable de entorno o gestor de secretos, nunca en el repositorio ni en código que llegue al navegador.
  • No lo registres. Evita volcar cabeceras completas en los logs; para depurar, registra si la verificación pasó y el id del evento.
  • Uno por entorno. Producción y staging deben tener secretos distintos, para que una fuga en pruebas no comprometa producción.

Un verificador completo en Node.js

Con todo lo anterior, una ruta de Express queda así. La función de verificación es pura y recibe la hora como parámetro, lo que facilita probarla:

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

const SECRET = process.env.WEBHOOK_SECRET; // whsec_… completo
const vistos = new Set(); // en producción: Redis o tu base de datos, con caducidad

export function verificarFirma(cuerpo, cabecera, secreto, ahora = Date.now() / 1000) {
  const partes = Object.fromEntries(
    cabecera.split(',').map((p) => p.trim().split(/=(.*)/s).slice(0, 2)),
  );
  const { t, v1 } = partes;
  if (!/^\d+$/.test(t ?? '') || !/^[0-9a-f]{64}$/.test(v1 ?? '')) return false;
  if (Math.abs(ahora - Number(t)) > 300) return false;
  const esperada = crypto.createHmac('sha256', secreto).update(`${t}.${cuerpo}`).digest();
  return crypto.timingSafeEqual(esperada, Buffer.from(v1, 'hex'));
}

const app = express();
app.post('/webhooks/push', express.raw({ type: 'application/json' }), (req, res) => {
  const cuerpo = req.body.toString('utf8');
  if (!verificarFirma(cuerpo, req.get('Castly-Signature') ?? '', SECRET)) {
    return res.sendStatus(400);
  }
  const evento = JSON.parse(cuerpo);
  if (!vistos.has(evento.id)) {
    vistos.add(evento.id);
    encolar(evento); // el trabajo lento, fuera de la petición
  }
  res.sendStatus(200);
});

Responde 2xx en cuanto verifiques; la documentación pide hacerlo en menos de 10 segundos. Los webhooks de castly son de mejor esfuerzo y no se reintentan, así que si tu servidor estuvo caído, concilia consultando la API, como se explica en la sección de webhooks de la documentación.

Pruébalo antes de producción

La verificación es código crítico y fácil de testear, porque puedes firmar tú mismo con un secreto de prueba. Con el runner de Node:

import test from 'node:test';
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import { verificarFirma } from './webhook.js';

const secreto = 'whsec_prueba';
const cuerpo = '{"id":"evt_1","type":"notification.clicked"}';
const ahora = 1763049608;
const firmar = (t, c) => crypto.createHmac('sha256', secreto).update(`${t}.${c}`).digest('hex');
const cabecera = (t, c) => `t=${t},v1=${firmar(t, c)}`;

test('acepta una firma correcta', () => {
  assert.equal(verificarFirma(cuerpo, cabecera(ahora, cuerpo), secreto, ahora), true);
});
test('rechaza un cuerpo modificado', () => {
  assert.equal(verificarFirma(cuerpo + ' ', cabecera(ahora, cuerpo), secreto, ahora), false);
});
test('rechaza una firma de hace 10 minutos', () => {
  assert.equal(verificarFirma(cuerpo, cabecera(ahora - 600, cuerpo), secreto, ahora), false);
});
test('rechaza cabeceras mal formadas sin lanzar', () => {
  assert.equal(verificarFirma(cuerpo, 'v1=abc', secreto, ahora), false);
  assert.equal(verificarFirma(cuerpo, `t=abc,v1=${firmar(ahora, cuerpo)}`, secreto, ahora), false);
});

Añade también un caso con otro secreto y otro con una firma de longitud incorrecta: es justo el que haría saltar timingSafeEqual si olvidas validar el formato.

Preguntas frecuentes

¿Por qué falla la verificación si el secreto es correcto?

Casi siempre porque se firma un cuerpo distinto del recibido: JSON vuelto a serializar, un middleware que ya lo parseó o un proxy que lo modificó. Revisa también que usas el secreto completo, la cabecera correcta y que el reloj del servidor no está desfasado.

¿Qué código HTTP devuelvo si la firma no es válida?

Un 400 o un 401, sin detalles en el cuerpo de la respuesta. Explicar el motivo exacto ayuda más a un atacante que a ti; guárdalo en tus logs internos.

¿Qué margen de tiempo debo aceptar en el timestamp?

Cinco minutos es un margen habitual: cubre desfases de reloj y retrasos de red sin dejar una ventana amplia para reenvíos. Si lo reduces mucho, rechazarás llamadas legítimas cuando haya latencia.

¿Basta con HTTPS o con una URL difícil de adivinar?

No. HTTPS cifra el transporte pero no autentica a quien llama, y una URL «secreta» acaba en logs, capturas o historiales. La firma es lo que prueba el origen y la integridad del cuerpo.

¿Puedo verificar la firma en el navegador?

No: el secreto quedaría expuesto a cualquiera que abra las herramientas de desarrollo. Los webhooks se reciben y verifican siempre en tu servidor o en una función serverless.

Ponlo en práctica con castlyNotificaciones push web y PWA para developers: un SDK de menos de 3 KB, una API REST y registros de entrega de cada dispositivo. Gratis hasta 10.000 notificaciones al mes.
Crear una cuenta gratis →

Envía tu primera notificación push hoy.

Crea un proyecto, añade el SDK y envía con una llamada a la API. Gratis para empezar.

Crear una cuenta gratis →