Firmado, sellado y destrozado: Por qué fallan las firmas de webhook detrás de un túnel
¿La firma de webhook falla detrás de un túnel? El cuerpo sin procesar, la reserialización de JSON, la tolerancia de la marca de tiempo y la firma de URL de Twilio rompen las comprobaciones HMAC. Así se soluciona.

Quick answer
Firmas de webhook que fallan tras un túnel: así se soluciona: quick comparison answer
Choose the tunnel tool based on the network model: public HTTPS URLs for webhooks and demos, private mesh access for internal apps, and managed infrastructure when policy controls matter most.
Which tunnel tool is best for public webhook testing?
Use a public HTTPS localhost tunnel with stable URLs. InstaTunnel focuses on webhook testing, demos, OAuth callbacks, and MCP endpoint workflows.
When should I choose a private network tool instead?
Choose a private mesh or Zero Trust tool when every user and service should stay inside a controlled private network.
Apuntas un túnel a localhost:3000, envías un evento de prueba y tu manejador responde 400 Invalid signature. La carga útil parece correcta. El secreto parece correcto. Entonces, ¿por qué la verificación de la firma de webhook falla solo cuando la solicitud pasa a través de un túnel o de un proxy inverso?
Una firma es una promesa sobre bytes exactos, temporización exacta y, para algunos proveedores, una URL exacta. Un túnel, proxy o middleware útil puede cambiar cualquiera de esos aspectos mientras la solicitud sigue pareciendo válida. Esta guía cubre las tres familias de fallos que los desarrolladores encuentran con mayor frecuencia: cuerpo crudo frente a JSON re-serializado, tolerancia de la marca de tiempo y la firma al estilo Twilio de la URL completa. Termina con una lista de verificación de depuración y una mirada a las herramientas de depuración disponibles a partir de noviembre de 2026.
Cómo funcionan las firmas de webhook
Prácticamente todos los proveedores utilizan un HMAC: el remitente combina un secreto compartido con algún “contenido firmado”, lo calcula y coloca el resultado en un encabezado. El receptor reconstruye el mismo contenido, calcula su propio HMAC y compara los dos en tiempo constante. Los proveedores difieren en qué va dentro del contenido firmado, y en el algoritmo de hash y la codificación.
| Proveedor | Encabezado | Contenido firmado | Algoritmo |
|---|---|---|---|
| Stripe | Stripe-Signature (t=…,v1=…) |
{timestamp}.{raw body} |
HMAC-SHA256, hex |
| GitHub | X-Hub-Signature-256 (sha256=…) |
raw payload only | HMAC-SHA256, hex |
| Slack | X-Slack-Signature (v0=…) |
v0:{timestamp}:{raw body} |
HMAC-SHA256, hex |
| Webhooks estándar (Svix, Resend, Clerk, Polar, OpenAI y otros) | webhook-signature (v1,…) |
{webhook-id}.{webhook-timestamp}.{raw body} |
HMAC-SHA256, Base64 |
| Twilio | X-Twilio-Signature |
full URL + sorted POST params | HMAC-SHA1, Base64 |
Stripe, Slack y Webhooks estándar incrustan una marca de tiempo en la firma. GitHub firma el cuerpo en crudo sin marca de tiempo. Twilio firma la URL. Cada diseño da a un túnel una forma distinta de romper las cosas.
Fallo #1: cuerpo crudo frente a JSON re-serializado
La causa más común de “desajuste de firma” no es un secreto incorrecto. Es un analizador JSON que se ejecutó antes de tu código de verificación.
Stripe firma el cuerpo de la solicitud crudo y sin modificaciones, y la mayoría de los frameworks web analizan el cuerpo y descartan los bytes originales. La documentación de Slack señala lo mismo: la forma cruda del cuerpo, con los espacios en blanco, es la que Slack usa para calcular la firma.
Cómo se ve lo manglado
Si analizas JSON y luego llamas a JSON.stringify(req.body) para alimentar el HMAC, estás verificando una cadena diferente a la que firmó el proveedor. La reserialización puede cambiar:
- Espacios en blanco y orden de claves, que rara vez coinciden byte a byte con los originales.
- Escape Unicode: un emisor puede escribir
"\u00e9"donde tu serializador escribe un literal"é". Ambos representan el mismo valor pero con bytes diferentes. - Formato de números: en JavaScript,
1.0se convierte en1, y los enteros mayores que 2^53 pierden precisión. - Codificación de caracteres: la guía de GitHub recomienda manejar las cargas útiles como UTF-8, ya que pueden contener Unicode.
Cada uno de estos produce un objeto con apariencia válida y una firma inválida.
La trampa de Express
En Express, el orden de los middleware decide todo, porque cualquiera de los analizadores de cuerpo que se ejecuta primero consume el flujo de la solicitud. Esto está roto:
app.use(express.json()); // runs first; req.body is now an object
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);
La ruta con el analizador crudo a nivel de ruta obtiene nada. Registra las rutas de webhook con express.raw() antes del analizador JSON global, o captura los bytes con el gancho verify:
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf; } // keep the exact bytes
}));
Hash req.rawBody, nunca JSON.stringify(req.body). Una guardia Buffer.isBuffer(req.body) en una ruta de cuerpo crudo convierte un “desajuste de firma” engañoso en un error de configuración evidente.
Dónde entra el túnel
Un túnel que solo retransmite solicitudes HTTP debería entregar los mismos bytes del cuerpo que recibió. Los problemas comienzan cuando algo en la ruta transforma la solicitud: un proxy que reescribe el cuerpo, un middleware que lo normaliza o una capa de inspección que lo re-encodes. Si la verificación pasa cuando llamas a la app directamente pero falla a través del túnel, compara los bytes crudos que tu app recibe con los que envió el proveedor.
Los inspectores de túneles ayudan aquí, con una advertencia. El Traffic Inspector de ngrok, por ejemplo, recopila solo metadatos de la solicitud por defecto, para evitar exponer secretos. Debes habilitar la captura completa en la configuración de tu cuenta antes de que registre cuerpos completos de solicitud y respuesta.
Una trampa más para el desarrollo local: el secreto de firma depende de dónde provino el evento. La guía de solución de problemas de Stripe dice que el error de verificación más común es usar el secreto de endpoint incorrecto. Tanto el endpoint del Dashboard como la Stripe CLI producen secretos que comienzan con whsec_, pero son secretos diferentes. No verifiques eventos reenviados por CLI con el secreto de un endpoint del Dashboard, o viceversa. Ninguna cantidad de manejo del cuerpo solucionará eso.
Fallo #2: tolerancia de marca de tiempo
Las firmas con marca de tiempo existen para evitar ataques de repetición. Un atacante que capture una solicitud válida puede reenviarla con su firma intacta, por lo que los receptores también verifican que la marca de tiempo sea reciente. El valor por defecto habitual es de cinco minutos:
- Las bibliotecas de Stripe rechazan eventos cuya marca de tiempo difiere en más de 300 segundos por defecto, y permiten anular la tolerancia.
- Slack te indica que rechaces las solicitudes cuyo
X-Slack-Request-Timestampdifiera de la hora local en más de cinco minutos. - Webhooks estándar las bibliotecas establecen por defecto una tolerancia de 300 segundos. Algunas implementaciones verifican en ambas direcciones y rechazan marcas de tiempo futuras así como las caducadas.
Para Webhooks estándar, webhook-id se mantiene igual entre reintentos del mismo evento, mientras que webhook-timestamp registra cuándo se firmó cada intento de entrega. La marca de tiempo es, por tanto, fresca en cada reintento del lado del proveedor.
GitHub es la excepción. Su firma no lleva marca de tiempo, así que no hay verificación de frescura. Deduplica con el ID X-GitHub-Delivery en su lugar.
Por qué los túneles hacen que las fallas de timestamp sean más probables
- Desfase del reloj en entornos de desarrollo. Un portátil que durmió durante la noche, o una VM o contenedor con un reloj desincronizado, puede desviarse varios minutos. Fuera de la tolerancia, cada solicitud falla aunque la firma sea correcta. Sincroniza el reloj primero.
- Reproducción de eventos capturados. Los inspectores de túneles permiten reenviar una solicitud capturada, incluso con ediciones. ngrok ofrece tanto una reproducción simple como una “reproducción con modificaciones”. Un evento de Stripe de hace una hora fallará la comprobación de tolerancia, lo cual es el comportamiento esperado. Re-firma con una marca de tiempo fresca, o amplía la tolerancia en una configuración solo para desarrollo.
- La entrega se retrasa en tránsito. Los reintentos del proveedor están firmados de nuevo, así que no es el problema. Una entrega única que tu túnel descarta, pone en cola o retrasa más allá de la ventana de tolerancia lo es.
En producción, mantén la tolerancia por defecto de 300 segundos y nunca desactives la verificación fuera de una prueba desechable. Registra el desfase de reloj calculado en cada rechazo para que los errores “timestamp caducado” y “firma incorrecta” aparezcan como errores separados. Las propias instrucciones de Slack tratan la verificación de la marca de tiempo como un paso distinto previo a la comparación de firmas.
Un truco de Webhooks estándar
La clave de firma de Webhooks estándar normalmente es el secreto con su prefijo whsec_ eliminado y el resto decodificado desde Base64. No todos los proveedores siguen eso al pie de la letra. Polar señala que Polar utiliza la cadena secreta completa, incluido el prefijo, como la clave HMAC cruda. Si haces la verificación manual en lugar de usar una biblioteca, consulta la documentación de tu proveedor.
Fallo #3: Twilio firma la URL completa
El esquema de Twilio sorprende a la gente, porque el cuerpo es solo parte de lo que se firma. Según la documentación de Twilio:
- Comienza con la URL completa de la solicitud, desde el protocolo hasta el final de la cadena de consulta.
- Para un POST con codificación de formulario, ordena los parámetros POST alfabéticamente por nombre y añade cada nombre y valor a la URL, sin delimitadores.
- Calcula un HMAC-SHA1 de esa cadena, con la clave de tu cuenta Auth Token.
- Codifica el resultado en Base64 y compáralo con
X-Twilio-Signature.
Como la URL está incrustada, un proxy que reescribe el esquema, el host o la ruta rompe la validación. La solicitud es legítima, pero tu código reconstruye una cadena diferente de la que Twilio firmó.
Las herramientas de validación de Twilio también esperan que pases todos los parámetros recibidos, no una lista codificada, ya que Twilio puede añadir parámetros en cualquier momento. Los parámetros de la cadena de consulta ya son parte de la URL, así que no los pases por separado tampoco.
Qué rompe la URL detrás de un túnel
- Esquema incorrecto. TLS termina en el borde, así que tu aplicación ve
http://mientras Twilio firmóhttps://. Recupera el esquema original conX-Forwarded-Proto, como explica la guía de terminación TLS de Node.js de Twilio. - Host incorrecto. Tu aplicación ve un nombre de host interno o
localhost:3000, pero Twilio firmó el nombre de host público del túnel. Si algo en la ruta reescribe el encabezadoHost, no lo uses para reconstruir la URL firmada. - Falta de ruta o cadena de consulta. Objetos de frameworks como
request.urlorequest.hostpueden devolver una URL parcial, y reconstruirhttps://hostsin la ruta cambia la cadena firmada. - Diagonal final. El valor firmado es una cadena plana, así que
/webhooky/webhook/producen firmas diferentes. Coincide exactamente con la URL en la consola de Twilio. - Puertos. El validador de twilio-ruby verifica la firma tanto con puerto explícito como sin él, con un comentario de código que señala que la generación de firmas en el back end de Twilio es inconsistente. Si haces la verificación manual, prueba ambas formas.
- Un hostname que cambia. Si tu túnel asigna un nuevo nombre de host aleatorio en cada sesión, actualiza la URL en Twilio y en tu validador. Un valor desactualizado falla cada solicitud.
Cuerpos JSON y bodySHA256
No todos los webhooks de Twilio son form-encoded. Para solicitudes application/json, Twilio añade un parámetro de consulta bodySHA256 a la URL de tu webhook, que contiene un hash SHA-256 del cuerpo crudo. Los SDK de Twilio realizan dos comprobaciones: el hash del cuerpo debe coincidir con el parámetro, y la firma, calculada sobre la URL que incluye ese parámetro, debe ser válida. Un middleware que mangles el cuerpo, por lo tanto, falla la comprobación del hash incluso si la lógica de la URL es perfecta. En Node, esto es tarea de validateRequestWithBody. Conserva también el cuerpo crudo aquí.
La solución más segura
- Confiar solo en los encabezados de reenvío de un proxy que controles. Reconstruye la URL a partir de
X-Forwarded-ProtoyX-Forwarded-Host. Esto funciona solo si un proxy de confianza los establece, y nunca debes confiar en ellos desde la Internet abierta. En Express, eso también significa configurar correctamentetrust proxypara quereq.protocolrefleje el esquema reenviado. - Codificar de forma fija la URL pública. Ya sabes qué configuraste en Twilio. Valída contra esa constante. Es tedioso y elimina toda la clase de errores de reconstrucción.
Twilio’s scheme has no timestamp, so there is no built-in replay window. If replays matter, deduplicate on identifiers such as MessageSid or CallSid.
Una lista de verificación de depuración de diez minutos
Trabaja desde la comprobación más barata hasta la más cara:
- ¿Secret correcto, del tipo correcto? ¿Secret de Stripe CLI frente al secreto del Dashboard,
Twilio Auth Token(la clave de firma) frente a una clave de API, un secreto rotado que aún no se ha propagado? GitHub omiteX-Hub-Signature-256por completo si no hay secreto configurado. - ¿Encabezado correcto? GitHub recomienda
X-Hub-Signature-256. El antiguoX-Hub-Signatureusa SHA-1 y se mantiene por fines de compatibilidad. Los eventos de prueba de Stripe pueden contener una entrada legadav0en el encabezado, así que verificav1e ignora otros esquemas. - ¿Bytes crudos? Registra la longitud del cuerpo que has hasheado y compáralo con
Content-Lengthque vio el túnel. - ¿Reloj dentro de la tolerancia? Imprime
now - timestampen cada rechazo. - Twilio: ¿qué URL estás hasheando? Regístrala y compárala carácter por carácter con la Consola. Prueba con y sin el puerto y sin la barra final.
- ¿Pasa cuando se envía directamente a la aplicación? Si es así, algo entre el proveedor y tu aplicación está cambiando la solicitud. Si no, el error está en tu código de verificación.
- ¿Comparación en tiempo constante? Usa
crypto.timingSafeEqualohmac.compare_digest. No solucionará un desajuste, pero la documentación de GitHub lo recomienda, y te ayuda a evitar filtraciones temporales mientras depuras.
Herramientas útiles para finales de 2026
- Stripe CLI.
stripe listenreenvía eventos a localhost y imprime su propio secreto de firma temporal, así que no se necesita URL pública. Usa ese secreto localmente. - ngrok. La guía Mux de ngrok indica un límite de 500 validaciones por mes en cuentas gratuitas.
@teamshift/webhook-inspect. Un paquete npm de código abierto que verifica firmas HMAC de Stripe, GitHub, Shopify, Slack, Twilio y genéricas y explica por qué falla una verificación. Su README describe tres comandos:verifyverifica una única solicitud.listencaptura webhooks localmente, con verificación.replayenvía una captura guardada a un servidor local.
Cuando la verificación falla, la herramienta intenta arreglos concretos y marca la causa confirmada.
Una advertencia sobre webhook-inspect: el listado de Socket muestra una única versión (0.1.0) y un único mantenedor. Su README incluye una captura de muestra fechada el 4 de octubre de 2026, pero no pude confirmar la fecha exacta de publicación. Trátalo como software en etapas tempranas y revísalo antes de enrutar secretos reales a través de él.
Sea la herramienta que uses, la técnica es idéntica: reproduce la cadena firmada por el proveedor y cambia una variable a la vez hasta que los HMAC coincidan.
Fortalecimiento de producción
- Verificar antes de analizar. Autentica primero, deserializa después.
- Separar errores internos por falta de encabezado, marca de tiempo caducada y firma incorrecta, mientras se devuelve al llamante una respuesta genérica.
- Soportar rotación de secretos. Stripe puede enviar múltiples firmas
v1mientras dos secretos están activos y Webhooks estándar permite varias firmas separadas por espacios, así que acepta cualquier coincidencia válida. - Deduplicar en
X-GitHub-Delivery, el ID de evento de Stripe,webhook-ido los SIDs de Twilio, ya que los reintentos son normales. - Usar una URL pública estable en desarrollo. Para la firma de URL al estilo de Twilio, un nombre de host de túnel que no cambie entre sesiones mantiene tu consola de proveedores y tu validador en sincronía.
- Nunca enviar una bandera de “omitir verificación” activada.
Preguntas frecuentes
¿Por qué mi firma se verifica localmente pero falla detrás de un túnel?
Usualmente los bytes del cuerpo (un analizador o proxy los alteró), el reloj (desfase o un evento reenviado), o la URL (un esquema, host o ruta diferente a la firmada). También descarta el secreto incorrecto, especialmente entre Stripe CLI y Dashboard. Verifica los bytes en crudo, el desfase y la URL reconstruida, en ese orden.
¿La firma de Twilio incluye una marca de tiempo?
No. Twilio firma la URL completa más los parámetros POST ordenados (o se apoya en bodySHA256 para cuerpos JSON). Stripe, Slack y Webhooks estándar incluyen marcas de tiempo.
¿Cuál es la tolerancia de marca de tiempo adecuada?
Cinco minutos (300 segundos) es el valor por defecto ampliamente usado en las bibliotecas de Stripe y Webhooks estándar, y es la ventana que Slack documenta. Aumenta solo en pruebas locales.
¿Puedo verificar una firma después de analizar el JSON?
No de forma fiable. El JSON re-serializado puede diferir en espacios, orden de claves, escapes y formato de números. Captura los bytes en crudo antes de que cualquier analizador se ejecute.
¿Por qué falla la validación de Twilio cuando mi host parece correcto?
Verifica el esquema, el puerto, la ruta, la cadena de consulta y la barra final. Un solo carácter cambia el HMAC. En caso de duda, codifica de forma fija la URL desde la Twilio Console.
Conclusión
Una firma de webhook es un pequeño contrato: Estos bytes exactos, en este momento, a esta dirección. Los túneles y proxies no son malintencionados, pero se ubican en el medio de ese contrato y pueden perturbar cualquier cláusula. Mantén el cuerpo crudo, mantén tu reloj honesto, vuelve a construir o codifica de forma fija la URL exacta y usa una herramienta de reproducción y diagnóstico cuando una desalineación no ceda al mirar los registros. Haz eso y “firmado, sellado y desfigurado” vuelve a ser “firmado, sellado y entregado”.
Fuentes
- Stripe: Resolve webhook signature verification errors
- LatticeStripe webhook documentation (raw body requirement and 300-second tolerance)
- GitHub Docs: Validating webhook deliveries
- GitHub Docs (Enterprise Server 3.16): Validating webhook deliveries
- Slack: Verifying requests from Slack
- OpenReplay: How to Verify a Webhook Signature
- Svix: Webhook signature glossary (Standard Webhooks)
- Hookdeck: Guide to OpenAI webhooks (Standard Webhooks adopters)
- Polar Express (Elixir) webhook documentation
- Glean: Webhook delivery (Standard Webhooks tolerance and signing key)
- Whatnot: Webhooks (webhook-id and webhook-timestamp behavior on retries)
- Twilio: Security and validating requests
- Twilio: Webhooks security (bodySHA256)
- Twilio Support: Fixing Request Validation 403 Errors Behind a Proxy
- Twilio: Handle SSL Termination when Validating Webhook Requests in Node.js
- twilio-ruby RequestValidator documentation
- ngrok: Stripe webhooks integration
- ngrok: Microsoft Teams webhooks (Traffic Inspector capture settings)
- ngrok: Mux webhooks (signature validation limits)
- Socket: @teamshift/webhook-inspect
Related InstaTunnel pages
Continue from this article into the most relevant product guides and workflows.
Related Topics
Keep building with InstaTunnel
Read the docs for implementation details or compare plans before you ship.