Firmas de webhook, explicadas
Última actualización:
Una firma de webhook es una cadena corta que el servicio emisor adjunta a cada entrega: un HMAC calculado sobre los bytes exactos de la petición con un secreto que solo comparten el emisor y el receptor. Existe para que el servidor receptor pueda responder a dos preguntas antes de actuar: ¿esto viene de verdad del servicio que dice venir? y ¿el cuerpo llegó exactamente como se envió? Este artículo explica cómo funciona realmente esa verificación: qué cubre el HMAC, por qué debe comprobarse sobre el cuerpo sin procesar y no sobre un objeto ya analizado, por qué la comparación debe ejecutarse en tiempo constante, cómo las marcas de tiempo acotan los ataques de repetición y por qué cada proveedor da un formato distinto a la cabecera. Si quieres ver primero una entrega real —cabeceras, firma y cuerpo sin procesar juntos—, apunta un emisor al probador de webhooks e inspecciona lo que llega.
Por qué una URL secreta no es autenticación
La protección de webhook más antigua es una URL imposible de adivinar: un segmento de ruta largo y aleatorio que solo conoce el servicio emisor. Parece una contraseña, pero se comporta como una dirección, y las direcciones se filtran. La URL queda escrita en registros de acceso, registros de proxies, paneles de monitorización, capturas de pantalla en informes de errores y las propias páginas de configuración del servicio emisor, donde cualquier ingeniero de soporte —o cualquiera que comprometa uno solo de esos sistemas— puede leerla. Una contraseña se puede almacenar con hash y rotar por usuario; una URL se copia literalmente en todos los sitios donde se usa, y cada copia es una invitación permanente.
El problema más profundo es lo que el endpoint hace con una petición una vez que llega. Los gestores de webhooks marcan facturas como pagadas, crean cuentas, conceden licencias, eliminan datos. Son acciones que merece la pena falsificar, y una petición falsificada le cuesta a un atacante una sola petición HTTP POST desde cualquier lugar de internet. TLS no ayuda aquí, porque TLS autentica tu servidor ante quien se conecta; no dice nada sobre quién es la parte que se conecta. Una firma traslada la confianza de «sabe dónde llamar» a «conoce un secreto que nunca aparece en la propia petición», que es una afirmación categóricamente más fuerte.
Qué demuestra realmente una firma HMAC
El emisor y el receptor comparten un secreto, generado cuando se registra el endpoint. Para cada entrega, el emisor calcula un HMAC —un hash con clave— sobre un conjunto definido de bytes: normalmente el cuerpo sin procesar de la petición, a menudo precedido de una marca de tiempo, a veces con un ID de entrega. El resultado viaja en una cabecera. El receptor recalcula el HMAC sobre los bytes que recibió, usando su propia copia del secreto, y compara los dos valores. Una coincidencia demuestra dos cosas a la vez: que el emisor poseía el secreto y que los bytes cubiertos son, bit a bit, los bytes que el emisor firmó. Cambia un solo carácter del campo del importe y el valor recalculado diverge por completo.
Conviene decir dos límites con claridad. Primero, una firma no es cifrado: el cuerpo viaja por la red legible para cualquiera que esté en posición de verlo —mantenerlo privado es tarea de TLS— y la firma solo responde del origen y de la integridad. Segundo, un hash simple del cuerpo no demostraría nada, porque cualquiera puede calcular un hash de cualquier cosa. El secreto es lo que convierte un hash en un código de autenticación de mensajes. La generador de hashes de este sitio calcula hashes sin clave, que están bien como sumas de comprobación, pero una suma de comprobación que cualquiera puede recalcular no autentica a nadie.
Verifica el cuerpo sin procesar, byte a byte
La causa más común de «la verificación falla en entregas auténticas» no es un atacante: es el propio framework del receptor. La firma se calculó sobre el cuerpo exactamente como se transmitió: este orden de claves, estos espacios en blanco, estos caracteres escapados, este formato de números. La mayoría de los frameworks web analizan amablemente el cuerpo JSON y lo convierten en un objeto antes de que se ejecute tu gestor, y si vuelves a serializar ese objeto para verificar, obtienes los mismos datos en bytes distintos: claves reordenadas, espacios añadidos, 1e3 convertido en 1000, unicode escapado o sin escapar. Idénticos en semántica, distintos en criptografía, y la verificación falla en una entrega que era perfectamente auténtica. La documentación de Stripe enuncia la regla sin rodeos: la verificación de firma requiere el cuerpo sin procesar, y el framework no debe manipularlo.
La consecuencia operativa es el orden: captura el cuerpo sin procesar de la petición antes de que nada lo analice, verifica la firma contra esos bytes y solo entonces analiza y procesa. Los proxies y el middleware que reescriben cuerpos —recodificando, normalizando, aplicando formato bonito— rompen las firmas con la misma seguridad que un analizador, y por eso la depuración empieza mirando la petición exactamente como llegó. Esa es la vista que te da una herramienta de recepción: cuando una entrega llega al probador de webhooks, lo que inspeccionas es la petición intacta, cabeceras y cuerpo tal como se transmitieron, contra la que una firma puede comprobarse con honestidad.
Comparación en tiempo constante, y por qué el reloj forma parte de la firma
Comparar los dos valores HMAC parece el paso trivial, y esconde una filtración sutil. Una comparación de cadenas ordinaria se detiene en el primer carácter que difiere, así que responde más rápido cuando un intento falla pronto y más lento cuando un prefijo más largo acierta por casualidad. Tras muchas peticiones, esa diferencia de tiempos permite a un atacante avanzar midiendo hacia una firma válida, carácter a carácter, sin llegar a conocer el secreto. Una comparación en tiempo constante examina siempre la longitud completa y tarda lo mismo difieran los valores en el primer carácter o en el último. La documentación de las plataformas es tajante al respecto: la de GitHub, por ejemplo, advierte explícitamente contra un operador de igualdad simple y señala funciones de tiempo constante como crypto.timingSafeEqual. Usa la función de la biblioteca; no escribas tú el bucle de comparación.
La frescura es la tercera propiedad, y una firma por sí sola no puede dártela: una entrega auténtica capturada y repetida literalmente lleva una firma perfectamente válida. Por eso muchos esquemas incorporan una marca de tiempo a los bytes firmados, y por eso los receptores rechazan las entregas cuya marca queda fuera de una ventana de tolerancia: cinco minutos por defecto en el esquema de Stripe, según su documentación. La ventana es un compromiso deliberado: lo bastante ancha para absorber desviaciones de reloj y colas lentas, lo bastante estrecha para que una petición capturada caduque rápido. Dentro de la ventana, la defensa recae en la idempotencia: registra el ID de entrega o de evento de todo lo que hayas procesado y descarta los duplicados, algo que necesitas de todos modos, porque los emisores honestos reintentan, y un reintento es una repetición con buenas intenciones.
Por qué la cabecera de cada proveedor tiene un aspecto distinto
La mecánica es estándar; el empaquetado nunca convergió. Stripe envía una cabecera Stripe-Signature con la forma t=<timestamp>,v1=<signature>, y puede incluir varios valores v1 a la vez. GitHub envía X-Hub-Signature-256, cuyo valor es la cadena con prefijo de algoritmo sha256=<hex digest>: sin marca de tiempo, de modo que una entrega de GitHub no lleva caducidad incorporada y la defensa contra repeticiones descansa por completo en deduplicar los ID de entrega. La especificación Standard Webhooks es un intento de zanjar la cuestión: cabeceras separadas para el ID de entrega, la marca de tiempo y la firma, con la firma calculada sobre los tres elementos más el cuerpo, para que los nuevos proveedores dejen de inventar variantes incompatibles. Las diferencias son accidentes históricos, no modelos de seguridad distintos: en todos los esquemas el invariante es el mismo: un secreto compartido, un conjunto definido de bytes cubiertos y una comparación en tiempo constante.
Los múltiples valores v1 del formato de Stripe existen para la operación a la que todo receptor se enfrenta tarde o temprano: rotar el secreto. Hecha de forma ingenua —cambiar el secreto, desplegar y cruzar los dedos—, la rotación rompe todo lo que está en vuelo, porque los reintentos y las entregas en cola firmados con el secreto antiguo dejan de verificar de repente. El patrón que funciona es una ventana en paralelo: durante el solapamiento se aceptan firmas con el secreto antiguo y con el nuevo, confirmas que las entregas verifican con el nuevo, y solo entonces se retira el antiguo. El mismo patrón de ventana aparece en el mundo JWT de los ID de clave y las claves solapadas, y el modo de fallo cuando los equipos se lo saltan es idéntico, como describe el artículo sobre errores con JWT. Un secreto que no puede rotarse sin tiempo de inactividad no se rotará ni siquiera cuando debería, incluido el día en que se filtre, y por eso la poco glamurosa ventana en paralelo forma parte del protocolo, no es un parche posterior.