You point a tunnel at localhost:3000, send a test event, and your handler answers 400 Invalid signature. The payload looks right. The secret looks right. So why does webhook signature verification fail only when the request passes through a tunnel or reverse proxy?
A signature is a promise about exact bytes, exact timing and, for some providers, an exact URL. A tunnel, proxy or helpful middleware can change any of those while the request still looks healthy. This guide covers the three failure families developers hit most: raw body versus re-serialized JSON, timestamp tolerance, and Twilio-style signing of the full URL. It ends with a debugging checklist and a look at the debugging tools available as of November 2026.
How webhook signatures work
Nearly every provider uses an HMAC: the sender combines a shared secret with some “signed content,” hashes it, and puts the result in a header. The receiver rebuilds the same content, computes its own HMAC and compares the two in constant time. Providers differ in what goes into the signed content, and in the hash algorithm and encoding.
| Provider | Header | Signed content | Algorithm |
|---|---|---|---|
| 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 |
| Standard Webhooks (Svix, Resend, Clerk, Polar, OpenAI and others) |
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 and Standard Webhooks bind a timestamp into the signature. GitHub signs the raw body with no timestamp. Twilio signs the URL. Each design gives a tunnel a different way to break things.
Failure #1: raw body versus re-serialized JSON
The most common cause of “signature mismatch” is not a wrong secret. It is a JSON parser that ran before your verification code.
Stripe signs the raw, unmodified request body, and most web frameworks parse the body and discard the original bytes. Slack’s documentation makes the same point: the raw form of the body, whitespace included, is what Slack uses to compute the signature.
What “mangled” looks like
If you parse JSON and then call JSON.stringify(req.body) to feed the HMAC, you are verifying a different string than the one the provider signed. Re-serialization can change:
- Whitespace and key order, which rarely match the original byte for byte.
-
Unicode escaping: a sender may write
"\u00e9"where your serializer writes a literal"é". Both are the same value but different bytes. -
Number formatting: in JavaScript,
1.0becomes1, and integers beyond 2^53 lose precision. - Character encoding: GitHub’s guidance is to handle payloads as UTF-8, since they can contain Unicode.
Each of these yields a valid-looking object and an invalid signature.
The Express trap
In Express, middleware order decides everything, because whichever body parser runs first consumes the request stream. This is broken:
app.use(express.json()); // runs first; req.body is now an object
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);
The route-level raw parser gets nothing. Register webhook routes with express.raw() before the global JSON parser, or capture the bytes with the verify hook:
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf; } // keep the exact bytes
}));
Hash req.rawBody, never JSON.stringify(req.body). A Buffer.isBuffer(req.body) guard in a raw-body route turns a misleading “signature mismatch” into an obvious configuration error.
Where the tunnel comes in
A tunnel that only relays HTTP requests should deliver the same body bytes it received. Trouble starts when something in the path transforms the request: a proxy that rewrites the body, a middleware that normalizes it, or an inspection layer that re-encodes it. If verification passes when you call the app directly but fails through the tunnel, compare the raw bytes your app receives with what the provider sent.
Tunnel inspectors help here, with one caveat. ngrok’s Traffic Inspector, for example, collects only request metadata by default, to avoid exposing secrets. You must enable full capture in your account settings before it records complete request and response bodies.
One more local-development trap: the signing secret depends on where the event came from. Stripe’s troubleshooting guide says the most common verification error is using the wrong endpoint secret. Both the Dashboard endpoint and the Stripe CLI produce secrets that start with whsec_, but they are different secrets. Don’t verify CLI-forwarded events with a Dashboard endpoint’s secret, or the other way around. No amount of body handling will fix that.
Failure #2: timestamp tolerance
Timestamped signatures exist to stop replay attacks. An attacker who captures a valid request can resend it with its signature intact, so receivers also check that the timestamp is recent. The common default is five minutes:
- Stripe’s libraries reject events whose timestamp is more than 300 seconds off by default, and let you override the tolerance.
-
Slack tells you to reject requests whose
X-Slack-Request-Timestampdiffers from local time by more than five minutes. - Standard Webhooks libraries default to a 300-second tolerance. Some implementations check both directions and reject future-dated timestamps as well as stale ones.
For Standard Webhooks, webhook-id stays the same across retries of the same event, while webhook-timestamp records when each delivery attempt was signed. The timestamp is therefore fresh on every provider-side retry.
GitHub is the exception. Its signature carries no timestamp, so there is no freshness check. Deduplicate on the X-GitHub-Delivery ID instead.
Why tunnels make timestamp failures likelier
- Clock drift on dev machines. A laptop that slept overnight, or a VM or container with a skewed clock, can run minutes off. Outside the tolerance, every request fails even though the signature is correct. Sync the clock first.
- Replaying captured events. Tunnel inspectors let you resend a captured request, including with edits. ngrok offers both a plain replay and “replay with modifications”. An hours-old Stripe event will fail the tolerance check, which is the feature working as designed. Re-sign with a fresh timestamp, or widen the tolerance in a development-only configuration.
- Delivery held up in transit. Provider retries are re-signed, so they aren’t the problem. A single delivery that your tunnel drops, queues or delays past the tolerance window is.
In production, keep the 300-second default and never disable the check outside a throwaway test. Log the computed clock skew on every rejection so “stale timestamp” and “bad signature” show up as separate errors. Slack’s own instructions treat the timestamp check as a distinct step before signature comparison.
A Standard Webhooks gotcha
The Standard Webhooks signing key is normally the secret with its whsec_ prefix removed and the remainder Base64-decoded. Not every provider follows that to the letter. Polar’s documentation notes that Polar uses the full secret string, including the prefix, as the raw HMAC key. If you hand-roll verification instead of using a library, check your provider’s docs.
Failure #3: Twilio signs the full URL
Twilio’s scheme surprises people most, because the body is only part of what gets signed. Per Twilio’s documentation:
- Start with the full URL of the request, from the protocol through the end of the query string.
- For a form-encoded POST, sort the POST parameters alphabetically by name and append each name and value to the URL, with no delimiters.
- Compute an HMAC-SHA1 of that string, keyed with your account Auth Token.
- Base64-encode the result and compare it with
X-Twilio-Signature.
Because the URL is baked in, a proxy that rewrites the scheme, host or path breaks validation. The request is genuine, but your code rebuilds a string different from the one Twilio hashed.
Twilio’s validation helpers also expect you to pass every received parameter, not a hardcoded list, since Twilio can add parameters at any time. Query-string parameters are already part of the URL, so don’t pass them separately as well.
What breaks the URL behind a tunnel
Twilio’s support guidance says the root cause of 403s behind a proxy is almost always a mismatch between the URL Twilio signed and the one your app reconstructs. The usual suspects:
-
Wrong scheme. TLS ends at the edge, so your app sees
http://while Twilio signedhttps://. Recover the original scheme withX-Forwarded-Proto, as Twilio’s Node.js SSL-termination guidance explains. -
Wrong host. Your app sees an internal hostname or
localhost:3000, but Twilio signed the public tunnel hostname. If anything in the path rewrites theHostheader, don’t use it to rebuild the signed URL. -
Missing path or query string. Framework objects like
request.urlorrequest.hostcan return a partial URL, and rebuildinghttps://hostwithout the path changes the signed string. -
Trailing slash. The signed value is a plain string, so
/webhookand/webhook/produce different signatures. Match the URL in the Twilio Console exactly. - Ports. The twilio-ruby validator checks the signature both with and without an explicit port, with a code comment noting that signature generation on Twilio’s back end is inconsistent about it. If you hand-roll validation, test both forms.
- A hostname that changes. If your tunnel assigns a new random hostname each session, update the URL in Twilio and in your validator. A stale value fails every request.
JSON bodies and bodySHA256
Not every Twilio webhook is form-encoded. For application/json requests, Twilio appends a bodySHA256 query parameter to your webhook URL, holding a SHA-256 hash of the raw body. Twilio’s SDKs then run two checks: the body’s hash must match the parameter, and the signature, computed over the URL including that parameter, must be valid. A body-mangling middleware therefore fails the hash check even if your URL logic is perfect. In Node, this is the job of validateRequestWithBody. Preserve the raw body here too.
The safest fix
-
Trust forwarding headers only from a proxy you control. Rebuild the URL from
X-Forwarded-ProtoandX-Forwarded-Host. This works only if a trusted proxy actually sets them, and you should never honor them from the open internet. In Express, that also means configuringtrust proxycorrectly soreq.protocolreflects the forwarded scheme. - Hardcode the public URL. You already know what you configured in Twilio. Validate against that constant. It is boring, and it removes the whole class of reconstruction bugs.
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.
A ten-minute debugging checklist
Work from the cheapest check to the most expensive:
-
Right secret, right kind? Stripe CLI versus Dashboard secret, Twilio Auth Token (the signing key) versus an API key, a rotated secret that has not propagated. GitHub omits
X-Hub-Signature-256entirely if no secret is configured. -
Right header? GitHub recommends
X-Hub-Signature-256. The olderX-Hub-Signatureuses SHA-1 and is kept for legacy purposes. Stripe test events can carry a legacyv0entry in the header, so verifyv1and ignore other schemes. -
Raw bytes? Log the length of the body you hash and compare it with the
Content-Lengththe tunnel saw. -
Clock within tolerance? Print
now - timestampon every rejection. - Twilio: which URL are you hashing? Log it and compare character by character with the Console. Try with and without the port and trailing slash.
- Does it pass when sent directly to the app? If yes, something between the provider and your app is changing the request. If no, the bug is in your verification code.
-
Constant-time compare? Use
crypto.timingSafeEqualorhmac.compare_digest. It will not fix a mismatch, but GitHub’s docs recommend it, and it keeps you from adding a timing leak while debugging.
Tools worth knowing in late 2026
-
Stripe CLI.
stripe listenforwards events to localhost and prints its own temporary signing secret, so no public URL is needed. Use that secret locally. - ngrok. Its Traffic Inspector can show and replay captured requests, and its provider-verification feature can check webhook signatures for supported providers (Stripe and Mux, among others) and block invalid requests before they reach your app. ngrok’s Mux guide notes a limit of 500 validations per month on free accounts.
-
@teamshift/webhook-inspect. A recent open-source npm package that verifies Stripe, GitHub, Shopify, Slack, Twilio and generic HMAC signatures and explains why a check fails. Its README describes three commands:-
verifychecks a single request. -
listencaptures webhooks locally, with verification. -
replaysends a saved capture to a local server.When verification fails, the tool tries concrete fixes and marks the confirmed cause.
-
A caveat on webhook-inspect: Socket’s listing shows a single version (0.1.0) and a single maintainer. Its README includes a sample capture dated 4 October 2026, but I could not confirm the exact publish date. Treat it as early-stage software and review it before routing real secrets through it.
Whatever tool you use, the technique is identical: reproduce the provider’s signed string and change one variable at a time until the HMACs match.
Production hardening
- Verify before you parse. Authenticate first, deserialize second.
- Separate internal errors for missing header, stale timestamp and bad signature, while giving the caller a generic response.
-
Support secret rotation. Stripe can send multiple
v1signatures while two secrets are active, and Standard Webhooks allows several space-delimited signatures, so accept any valid match. -
Deduplicate on
X-GitHub-Delivery, Stripe’s event ID,webhook-idor Twilio SIDs, since retries are normal. - Use a stable public URL in development. For Twilio-style URL signing, a tunnel hostname that does not change between sessions keeps your provider console and your validator in sync.
- Never ship a “skip verification” flag enabled.
Frequently asked questions
Why does my signature verify locally but fail behind a tunnel?
Usually the body bytes (a parser or proxy altered them), the clock (drift or a replayed event), or the URL (a different scheme, host or path than the one signed). Also rule out the wrong secret, especially Stripe CLI versus Dashboard. Check the raw bytes, the skew and the reconstructed URL, in that order.
Does Twilio’s signature include a timestamp?
No. Twilio signs the full URL plus sorted POST parameters (or relies on bodySHA256 for JSON bodies). Stripe, Slack and Standard Webhooks include timestamps.
What is the right timestamp tolerance?
Five minutes (300 seconds) is the widely used default across Stripe’s and Standard Webhooks’ libraries, and it is the window Slack documents. Widen it only in local testing.
Can I verify a signature after parsing the JSON?
Not reliably. Re-serialized JSON can differ in whitespace, key order, escaping and number formatting. Capture the raw bytes before any parser runs.
Why does Twilio validation fail when my host looks right?
Check scheme, port, path, query string and trailing slash. One character changes the HMAC. When in doubt, hardcode the URL from the Twilio Console.
Conclusion
A webhook signature is a small contract: these exact bytes, at this time, to this address. Tunnels and proxies are not malicious, but they sit in the middle of that contract and can disturb any clause. Keep the raw body, keep your clock honest, rebuild or hardcode the exact URL, and use a replay-and-diagnose tool when a mismatch will not yield to staring at logs. Do that and “signed, sealed, mangled” goes back to being “signed, sealed, delivered.”
Sources
- 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
Originally published at https://instatunnel.my/blog/signed-sealed-mangled-why-webhook-signatures-fail-behind-a-tunnel
Top comments (1)
Raw-body fidelity is the one that bites most people: if any middleware re-serializes JSON or normalizes headers, the HMAC computed over the exact received bytes no longer matches, and the fix is usually to capture the body before parsing and verify against the raw buffer. The URL-signing case has a desktop analogue too - in Electron, when you intercept requests via a custom protocol or a service worker, the effective request line can change enough to break providers that sign the full URL. A constant-time comparison plus a monotonic timestamp window with a small skew buffer behind proxies has kept our webhook handlers green for years.