CanHook
Features Pricing Docs FAQ Blog Log in Get started free

Why Twilio Webhook Signatures Fail in Production Handlers

By The CanHook Team · September 7, 2026 · 1199 words
In short

Twilio webhook signature verification usually fails because something changed the exact string Twilio signed — a proxy rewriting the URL, middleware parsing the body first, or a trailing-slash mismatch — not because the Auth Token is wrong. Twilio signs the URL plus sorted POST parameters (or the raw body for JSON webhooks) with HMAC-SHA1, so verification means rebuilding that same string byte-for-byte and comparing in constant time.

A Twilio signature check that fails on a legitimate request almost never means your Auth Token is wrong. It means something between Twilio and your handler changed the exact string Twilio signed — the URL, the raw body, or the order parameters arrived in. Here's exactly how Twilio builds X-Twilio-Signature, the specific things that silently break verification in production, and how to test your handler against a real captured request instead of a guess.

What Is the X-Twilio-Signature Header?

X-Twilio-Signature is an HMAC-SHA1 signature, base64-encoded, that Twilio attaches to every webhook request it sends to your application. It lets your server confirm the request came from Twilio and was not forged or sent by a third party who found your webhook URL. Twilio computes the signature using your account's Auth Token as the HMAC key, so anyone without that token cannot produce a matching signature no matter what payload they send.

How Does Twilio Build the Signature?

For a standard form-encoded webhook — voice, SMS, and most legacy webhooks — Twilio builds one string: your full webhook URL, followed by every POST parameter sorted alphabetically by key, with each key and its value concatenated directly onto the string with no delimiters between them. It then computes base64(HMAC-SHA1(url + sorted_params, your_auth_token)) and sends the result in the header, exactly as Twilio's request validation documentation specifies.

For webhooks that send a JSON body instead of form-encoded params, the signed string is just the URL plus the raw, unparsed request body — no sorting, because there are no discrete POST parameters to sort. Mixing these two schemes up is one of the most common reasons a signature check fails on a request that is otherwise legitimate.

How Do You Verify a Twilio Signature in Your Handler?

The check is symmetric: rebuild the exact string Twilio signed, hash it with the same Auth Token, and compare the result to the header in constant time.

  1. Capture the exact URL Twilio called, including protocol, host, path, and query string — not the URL your framework thinks it's serving.
  2. Read the raw POST body before any framework middleware reorders, decodes, or reserializes it.
  3. For form-encoded requests, sort the parsed parameters by key and concatenate each key and value onto the URL string.
  4. Compute HMAC-SHA1 over that string using your Auth Token as the key, then base64-encode the digest.
  5. Compare your computed value to X-Twilio-Signature with a constant-time comparison, never a plain string equality check.

The constant-time requirement is not paranoia: a naive byte-by-byte string comparison returns as soon as it finds the first mismatched character, so the response time leaks how many leading bytes were correct. An attacker with enough attempts can use that timing difference to reconstruct a valid signature one byte at a time without ever learning your Auth Token.

const crypto = require('crypto');

function verifyTwilioSignature(authToken, url, params, signature) {
  const data = Object.keys(params)
    .sort()
    .reduce((acc, key) => acc + key + params[key], url);

  const expected = crypto
    .createHmac('sha1', authToken)
    .update(Buffer.from(data, 'utf-8'))
    .digest('base64');

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}

const ok = verifyTwilioSignature(
  process.env.TWILIO_AUTH_TOKEN,
  'https://example.com/webhooks/sms',
  req.body,
  req.headers['x-twilio-signature']
);

Note that crypto.timingSafeEqual throws if the two buffers are different lengths, so wrap it in a length check first — a length mismatch means the signature is wrong anyway, so treat that as a failed verification rather than an exception to crash on.

What Silently Breaks Twilio Signature Verification?

Every one of these produces a real, well-formed request with a signature that simply does not match — nothing in the response points at the actual cause.

What changedWhy it breaks the signature
Reverse proxy or load balancer rewrites the URLTwilio signed the URL it called; your handler sees a different one (http vs https, a stripped port, a rewritten path)
Body parsed or mutated before verificationMiddleware that trims whitespace, reorders keys, or re-encodes the body changes the exact bytes Twilio signed
Trailing slash mismatchTwilio's configured webhook URL and the URL your router normalizes to must match byte-for-byte
Multiple values for one parameter keyNaive sort-and-concatenate logic assumes one value per key; repeated keys need the exact ordering Twilio used

How Do You Test This Without Sending a Real SMS or Call?

You cannot forge a valid X-Twilio-Signature without the real Auth Token, so testing your verification logic against a fake payload only proves your rejection path works, not your acceptance path. What you actually need is visibility into a real Twilio request — headers and body, byte-for-byte — so you can replay it against your handler as many times as it takes to get the parsing right.

Point a spare Twilio webhook at a CanHook catch URL during development. CanHook stores the method, every header including X-Twilio-Signature, the raw body, and the content-type for each request it receives, so you can read the exact string Twilio actually sent instead of guessing at it from documentation.

curl -X POST "https://canhook.com/h/abc123def456" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "X-Twilio-Signature: PASTE_REAL_SIGNATURE_HERE" \
  --data-urlencode "From=+15551234567" \
  --data-urlencode "Body=test message" \
  --data-urlencode "MessageSid=SM00000000000000000000000000000000"

Once you've captured a real request, replay it against your local dev server as many times as you need while you fix parsing order or middleware — the signature stays valid because the bytes never change.

How Do You Forward a Verified Webhook to Another Service?

Once a Twilio webhook is verified, many teams need to forward it to a CRM, a support queue, or an internal service that shouldn't see raw Twilio traffic directly. A relay rule — available starting on the Pro plan — takes a captured request and forwards it to a destination URL after your endpoint's mock response has already returned to Twilio, so the forward never delays Twilio's own timeout. A template transform can reshape the payload before it leaves, substituting {{From}} or {{Body}} into a new JSON structure your downstream service expects, and a filter transform can strip everything except the fields you actually need. Every outbound relay destination is resolved and checked against private and reserved IP ranges before the request goes out, so pointing a rule at an internal address by mistake fails closed instead of quietly succeeding. If your downstream service is briefly unavailable, a failed delivery retries on its own schedule — 60 seconds, then 5 minutes, then 25 minutes, then 2 hours — instead of dropping the verified webhook on the first timeout.

Is This Worth Setting Up for One Webhook?

If you're only receiving one Twilio webhook and you can already trigger it on demand, hand-verifying against a captured request is a ten-minute job and you don't need anything more. The setup pays for itself once you have more than one provider or more than one environment — a catch URL is the same tool whether you're debugging Twilio's signature, Stripe's, or GitHub's, so you learn the workflow once instead of building a one-off test harness per integration.

Create a free CanHook endpoint, point your Twilio webhook at its catch URL during development, and read the exact headers and body Twilio sent — it takes about a minute and there's nothing to install.

Frequently asked questions

What is the X-Twilio-Signature header?

X-Twilio-Signature is an HMAC-SHA1 signature, base64-encoded, that Twilio attaches to every webhook request. It proves the request came from Twilio, using your account's Auth Token as the shared secret that only Twilio and your server know.

How does Twilio compute the webhook signature?

For form-encoded webhooks, Twilio concatenates your full URL with every POST parameter sorted alphabetically by key, then computes a base64-encoded HMAC-SHA1 hash of that string using your Auth Token. JSON webhooks sign the URL plus the raw body instead.

Why does my Twilio signature verification fail on a real request?

The most common causes are a reverse proxy rewriting the URL Twilio actually called, middleware parsing or reformatting the body before verification runs, and a trailing-slash mismatch between the configured webhook URL and the one your router sees.

Can I verify a Twilio signature without parsing the request body first?

You should read the raw body before any framework middleware touches it. Twilio signs the exact bytes it sent, so reordering keys, trimming whitespace, or re-encoding the body before verification changes the string and breaks the match.

Does Twilio sign JSON webhooks the same way as form-encoded ones?

No. Form-encoded webhooks are signed as the URL plus sorted, concatenated POST parameters. JSON webhooks are signed as the URL plus the raw, unparsed request body, since there are no discrete parameters to sort.

How do I test Twilio signature verification without triggering a real SMS or call?

Point a spare Twilio webhook at a CanHook catch URL during development to capture the real headers and body Twilio sends, then replay that exact request against your handler as many times as you need while you fix your parsing logic.