DEV Community

EventDock
EventDock

Posted on • Originally published at eventdock.app

How to Validate Twilio Webhook Signatures (and the Proxy Trap That Breaks Them)

Twilio's webhook signature is different from most, and it catches people in a specific way: the signature is computed over the full request URL, not just the body. So the moment your app sits behind a load balancer or a proxy that rewrites the scheme or host, the URL your server reconstructs stops matching the URL Twilio signed, and every request fails validation even though nothing is wrong. This post shows the exact scheme, the URL-reconstruction trap, and why losing a Twilio status callback is usually permanent.

For how this compares to the other major providers, see webhook signature verification compared across Stripe, Paddle, HubSpot, Slack, and Twilio.

What Twilio signs

Twilio sends an X-Twilio-Signature header with every request to your webhook. It is built like this:

  1. Start with the full URL Twilio requested, exactly as you configured it, including the scheme, host, path, and any query string.
  2. If the request is a POST with form-encoded parameters, take those POST params, sort them alphabetically by key, and append each one to the URL string as the key immediately followed by its value, with no separators.
  3. Compute an HMAC-SHA1 of that combined string, keyed with your account Auth Token (not an API key).
  4. Base64-encode the result. That is what lands in X-Twilio-Signature.

To validate, you rebuild the same string, compute your own HMAC-SHA1, and compare. The key is the Auth Token from your Twilio Console. Two things about this scheme surprise people: the URL is part of the signed material, and the POST parameters get concatenated onto it in sorted order rather than being hashed as a body.

The URL-reconstruction trap

Because the URL is signed, your server has to reconstruct the exact URL Twilio used. That is harder than it sounds in production:

  • Scheme. You configured an https:// webhook, but your app sits behind a load balancer that terminates TLS and forwards plain http:// internally. Your framework sees http, rebuilds an http:// URL, and the signature no longer matches. Use the X-Forwarded-Proto header to recover the original scheme.
  • Host and port. A proxy can change the host your app sees. The signed URL used the public host Twilio called, so you have to reconstruct that one, not the internal one.
  • Query string. If you configured the webhook with query parameters, they are part of the signed URL and must be present, in the same order.

This is the single most common reason Twilio validation fails in production and passes on localhost. On localhost there is no proxy, so the reconstructed URL matches. Ship it behind a load balancer and it breaks.

Validating in Node.js

Twilio ships an official validator in its SDK, and you should use it rather than hand-rolling the HMAC. The important part is feeding it the correct URL and the raw POST params.

import twilio from 'twilio';

app.post('/webhooks/twilio',
  express.urlencoded({ extended: false }),   // Twilio posts form-encoded params
  (req, res) => {
    const signature = req.get('X-Twilio-Signature');

    // Rebuild the EXACT public URL Twilio called. Honor the proxy's forwarded proto/host.
    const proto = req.get('X-Forwarded-Proto') || req.protocol;
    const host  = req.get('X-Forwarded-Host')  || req.get('Host');
    const url   = proto + '://' + host + req.originalUrl;

    const valid = twilio.validateRequest(
      process.env.TWILIO_AUTH_TOKEN,   // the signing key
      signature,
      url,
      req.body                         // the parsed POST params, as an object
    );

    if (!valid) return res.status(403).send('bad signature');

    res.type('text/xml').send('<Response/>');   // ack fast
    handleTwilioEvent(req.body).catch(console.error);
  }
);
Enter fullscreen mode Exit fullscreen mode

If you validate by hand instead, the rule is the one above: URL, then each POST param appended as sorted key + value, HMAC-SHA1 with the Auth Token, Base64. A JSON body uses a different mechanism (Twilio adds a bodySHA256 query parameter), so stick to the SDK if you accept JSON.

Trust the right forwarded headers

Reading X-Forwarded-Proto and X-Forwarded-Host only works if a proxy you trust actually sets them. In Express, set app.set('trust proxy', true) so req.protocol reflects the forwarded scheme, and make sure your load balancer is configured to pass the original host through. If you do not control the proxy headers, the safest fix is to hardcode the exact public URL you configured in Twilio and validate against that constant, since you already know what it is.

A dropped Twilio callback is usually gone for good

Signature validation confirms a request really came from Twilio. It does nothing for the callbacks you never receive. A message-status or call-status callback tells you a text was delivered or a call completed, and Twilio's retry behavior for these is limited, so you should not count on a retry to cover a callback you dropped. If your endpoint is mid-deploy or times out when that callback fires, your records can end up saying a message is still "sending" that actually delivered, or you miss a failed delivery you needed to react to.

This is where a reliability layer earns its place. You point Twilio at EventDock instead of your app. EventDock validates the request, stores it, and acknowledges Twilio right away inside the timeout, then forwards it to your app with its own retries and a dead-letter queue you can replay by hand. If your app is down for an hour, the callbacks wait in the queue and arrive once it recovers, instead of vanishing.

You can wire it up on the free tier and watch every Twilio callback get captured and delivered. Start with EventDock free, or read how the exactly-once processing pattern handles the duplicate deliveries that any retrying pipeline will eventually produce.

Top comments (0)