|

Seguridad de webhooks: el checklist antes de producción

En pocas palabras: Antes de producción, validá la firma HMAC contra el raw body (no el JSON parseado), compará en tiempo constante y nunca con ==, chequeá el timestamp para frenar replays y respondé 401 ante cualquier fallo. Esos cuatro controles cubren el 90% de los agujeros de webhooks.

La mayoría de los agujeros de seguridad de webhooks no son ataques exóticos: son el mismo puñado de errores que se repiten cuando alguien verifica una firma una vez, ve que pasa y sigue de largo. El checklist que publicó el equipo de EventDock apunta a esos descuidos, verificar contra el raw body y comparar en tiempo constante entre ellos, antes de que el endpoint llegue a producción.

Un webhook es una notificación HTTP que un servicio externo (Stripe, Slack, HubSpot, Paddle) le manda a tu servidor cuando pasa algo: un pago, un mensaje, un cambio de estado. La seguridad de webhooks es el conjunto de validaciones que confirman que ese POST vino de quien dice venir y no de un atacante que descubrió tu URL. Sin esas validaciones, cualquiera que conozca el endpoint puede inyectar eventos falsos.

En 30 segundos

  • El bug número uno: verificar la firma contra el JSON ya parseado en vez del raw body. El HMAC se calcula sobre los bytes exactos, y el parseo los cambia.
  • Nunca compares con ==: una comparación normal corta al primer byte distinto y te revela cuántos acertaste. Usá comparación en tiempo constante.
  • Validá timestamp: sin chequear la antigüedad del evento, un atacante te reenvía un payload viejo (replay attack).
  • Idempotencia obligatoria: los providers reintentan, así que el mismo evento puede llegar dos veces. Guardá el ID y descartá duplicados.
  • Probá con payloads inválidos en staging antes de ir a producción. Firma mal, body cambiado, timestamp viejo: todo tiene que rebotar.

¿Cuáles son los errores de seguridad más comunes en webhooks?

Los errores más comunes son cuatro: verificar la firma contra el body parseado en lugar del raw, comparar firmas con ==, no validar el timestamp del evento y no manejar reintentos duplicados. Según el checklist publicado por EventDock (julio 2026), ninguno de estos es sofisticado, pero cada uno ya quemó una integración real.

El patrón es siempre el mismo. Alguien conecta un webhook de Stripe, copia el ejemplo de la doc, ve que la firma valida en el primer intento y da el tema por cerrado. El problema es que ese “funciona” tapa media docena de casos borde que no se ven hasta que un atacante, o un mal día de red, los encuentra.

¿Y qué pasa cuando falla? No salta una excepción prolija. Te entran eventos falsos, se duplican operaciones, o alguien reconstruye tu firma byte a byte sin que te enteres. Por eso el checklist se corre antes de ir a producción, no después del incidente. Más contexto en almacenar credenciales y secrets.

¿Cómo verificar firmas contra el raw body sin parsear JSON?

Tenés que capturar el body crudo, sin parsear, verificar el HMAC contra esos bytes exactos, y recién ahí parsear el JSON. La firma que manda el provider es un HMAC calculado sobre los bytes literales que envió. Si tu framework parsea el JSON y vos reconstruís un string a partir del objeto, los bytes ya no son idénticos y la firma correcta nunca coincide.

Pensá en lo que cambia el parseo. El orden de las claves se puede mover, los espacios en blanco se colapsan, los números se reformatean (un 1.0 que vuelve como 1). Nada de eso altera el “significado” del JSON, pero altera cada byte, y el HMAC es despiadado con eso: un byte distinto, hash distinto, verificación fallida.

La regla vale igual para Stripe, Paddle, HubSpot y Slack. Lo que cambia es cómo agarrás el raw body en cada framework:

  • Express: usás el middleware express.raw() en la ruta del webhook para que el body llegue como Buffer sin parsear.
  • Next.js (route handlers): leés el cuerpo con await req.text() antes de tocar req.json().
  • Cloudflare Workers: tomás los bytes con await req.arrayBuffer() o req.text().
  • FastAPI / Flask (Python): pedís await request.body() o request.get_data() antes de que nadie deserialice.

El error clásico: dejar un middleware global de JSON parseando todo antes de que llegue a la ruta del webhook. Cuando llega tu handler, el raw body ya se lo comió el parser y no lo recuperás. Tema relacionado: elegir infraestructura performante.

¿Por qué comparar firmas en tiempo constante y no usar ==?

Porque una comparación de strings normal retorna tan pronto encuentra el primer carácter distinto, y esa diferencia mínima de tiempo le dice al atacante cuántos bytes iniciales acertó. Con suficientes intentos, forja una firma válida byte a byte. Se llama timing attack y es la razón por la que existe la comparación en tiempo constante, que compara los dos valores completos siempre, sin importar dónde difieran.

Cada lenguaje tiene su función para esto. En Node usás crypto.timingSafeEqual, que compara dos Buffers en tiempo fijo. En Python está hmac.compare_digest. En Go, hmac.Equal. La firma de la trampa es tentadora justamente porque expected === received parece inofensivo y anda perfecto en los tests.

// MAL: filtra timing
if (firmaEsperada === firmaRecibida) { ... }

// BIEN: tiempo constante (Node)
const crypto = require('crypto');
const ok = crypto.timingSafeEqual(
 Buffer.from(firmaEsperada),
 Buffer.from(firmaRecibida)
);

Ojo con un detalle: timingSafeEqual tira error si los dos buffers tienen distinto largo. Chequeá el largo antes, o normalizá, para no romper el handler con un input malformado.

¿Cuáles son las validaciones de headers y timestamps obligatorias?

Más allá de la firma, tenés que validar que el evento sea reciente y que no lo estés procesando dos veces. La firma sola confirma autenticidad, pero no frescura: un atacante que capturó un payload legítimo firmado te lo puede reenviar tal cual mañana, y la firma sigue siendo válida. Eso es un replay attack.

Las validaciones que conviene sumar:

  • Timestamp reciente: muchos providers mandan la hora del evento en el header firmado. Rechazá lo que tenga más de unos minutos (Stripe, por ejemplo, expone el timestamp dentro de su header de firma).
  • Idempotency key: guardá el ID del evento y descartá si ya lo procesaste. Los reintentos del provider son normales, no una anomalía.
  • Headers esperados: chequeá que estén los headers que el servicio siempre manda. Un POST sin el header de firma se descarta antes de gastar CPU en el HMAC.
  • Rango de IP de origen: si el provider publica sus rangos, filtrá por ahí como capa extra (nunca como única defensa, las IP se pueden falsear detrás de proxies).

La idempotencia es la que más se olvida. Si tu webhook dispara un cobro o manda un mail, procesar el mismo evento dos veces no es un detalle estético: es plata o es spam. Para más detalles técnicos, mirá guardar logs y eventos.

Checklist de seguridad de webhooks antes de ir a producción

Este es el resumen ejecutable. Si tu endpoint marca todos los casilleros, estás cubierto contra los errores que enumera el checklist de EventDock:

  • Verificar contra el raw body: HMAC sobre los bytes exactos, parsear el JSON recién después.
  • Comparación en tiempo constante: timingSafeEqual o equivalente, nunca ==.
  • Validar timestamp: rechazar eventos con más de unos minutos de antigüedad.
  • Manejar idempotencia: guardar el ID del evento y descartar duplicados.
  • Chequear headers: descartar requests sin el header de firma esperado.
  • Rate limiting: limitar la cantidad de requests para que nadie te martille el endpoint probando firmas.
  • Logging sin secrets: loguear el evento sí, pero nunca la firma completa ni el signing secret.
  • Probar en staging: mandar firmas mal, bodies alterados y timestamps viejos, y confirmar que todo rebota.

Correlo como paso de deploy, no como buena intención. Si tenés el endpoint corriendo sobre tu propio hosting o un VPS, sumá el rate limiting a nivel servidor además del de aplicación: dos capas siempre son mejores que una.

¿Cómo implementar validación segura según el framework?

La lógica es idéntica en todos lados, capturar raw body, calcular HMAC, comparar en tiempo constante, lo que cambia es el punto exacto donde cada framework parsea el body. Acá está el mapa por stack:

FrameworkCapturar raw bodyComparación seguraGotcha principal
Expressexpress.raw({type:'application/json'}) en la rutacrypto.timingSafeEqualMiddleware global de JSON que parsea antes que tu ruta
Next.js (route handler)await req.text()crypto.timingSafeEqualLlamar req.json() primero pierde el raw
Cloudflare Workersawait req.arrayBuffer()crypto.subtle / comparación manual seguraConsumir el body una sola vez (stream)
FastAPI (Python)await request.body()hmac.compare_digestDepender del modelo Pydantic ya parseado
Flask (Python)request.get_data()hmac.compare_digestrequest.json dispara el parseo
seguridad de webhooks diagrama explicativo

Fijate que en Cloudflare Workers el body es un stream que se consume una sola vez. Si lo leés para verificar y después querés volver a leerlo parseado, ya no está. Guardá el texto en una variable y parseá esa variable.

Errores comunes al asegurar webhooks

Estos son los tropiezos que se ven una y otra vez, con la corrección al lado:

  • Verificar contra el JSON reconstruido: parseás, volvés a stringificar y comparás. Los bytes ya no coinciden y la firma legítima falla. Solución: guardá el raw body y verificá contra eso.
  • Comparar con == o ===: abre la puerta al timing attack. Solución: función de tiempo constante, siempre.
  • Olvidar el timestamp: la firma valida pero el evento es viejo. Un payload capturado se reenvía sin problema. Solución: rechazá eventos fuera de una ventana de pocos minutos.
  • No manejar reintentos: el provider reintenta y vos cobrás dos veces. Solución: idempotencia por ID de evento.
  • Loguear el signing secret: queda en texto plano en tus logs y cualquiera con acceso a los logs falsifica firmas. Solución: enmascarar o no loguear nunca los secrets.

Preguntas Frecuentes

¿Qué es un webhook y cómo funciona?

Un webhook es una notificación HTTP que un servicio externo envía a una URL de tu servidor cuando ocurre un evento, como un pago o un mensaje. A diferencia de una API que vos consultás, el webhook te empuja el dato apenas pasa. Por eso hay que validar que ese POST vino del servicio real y no de un impostor. En automatizar pruebas de seguridad profundizamos sobre esto.

¿Por qué verificar contra el raw body y no contra el JSON parseado?

Porque la firma es un HMAC calculado sobre los bytes exactos que envió el provider, y el parseo del JSON cambia esos bytes (orden de claves, espacios, formato de números). Si comparás contra un string reconstruido, la firma correcta nunca coincide. Capturá el body crudo, verificá y parseá después.

¿Cómo prevenir un replay attack en webhooks?

Validando el timestamp del evento y usando idempotencia. Rechazá cualquier payload cuya hora firmada tenga más de unos minutos, así un evento capturado no se puede reenviar más tarde. Sumá un registro de IDs de eventos ya procesados para descartar reenvíos que caigan dentro de la ventana.

¿Qué es una comparación en tiempo constante?

Es una comparación que tarda lo mismo sin importar en qué byte difieren los dos valores, por lo que no filtra información al atacante. Funciones como crypto.timingSafeEqual en Node o hmac.compare_digest en Python la implementan. Evitan que alguien reconstruya una firma válida midiendo tiempos de respuesta.

¿La verificación de firma alcanza para asegurar un webhook?

No. La firma confirma autenticidad, pero no cubre replay ni duplicados. Un webhook seguro suma validación de timestamp, idempotencia, chequeo de headers, rate limiting y logging sin secrets. La firma bien hecha es la base, no el checklist completo.

Conclusión

La seguridad de webhooks no falla por ataques ingeniosos, falla por el mismo puñado de descuidos repetidos: firma verificada contra el body equivocado, comparación con ==, timestamp que nadie mira, reintentos que nadie maneja. El checklist de EventDock los ordena en algo que podés correr antes de cada deploy.

Hacé una sola cosa hoy: revisá si tu endpoint verifica contra el raw body y compara en tiempo constante. Esos dos puntos cubren los bugs más frecuentes. El resto (timestamp, idempotencia, rate limiting, logging limpio) lo agregás como pasos de una lista que corrés en staging con payloads inválidos hasta que todo rebote como corresponde.

Fuentes

Te puede interesar...