WebhookToolkit
Twilio

Twilio signature validation failed — find out why

Last updated: July 2026

You have an X-Twilio-Signature header, your code says it does not match, and Twilio's docs answer with an SDK to install. That does not tell you which of your URL, your params or your token is wrong. So paste the three of them below: we recompute the signature for every plausible URL form and show you the one that matches. The matching line is your diagnosis.

In short: a Twilio signature fails almost always because the URL your code rebuilds is not byte-identical to the URL Twilio called — usually http:// instead of https:// behind a reverse proxy. The algorithm is rarely the problem.

Check a signature that Twilio rejected

Everything runs in your browser (WebCrypto HMAC-SHA1). Your Auth Token is never sent to our servers, and no request leaves this page while you use the tool. Open the network tab and check — we would rather you did.

The exact string Twilio signs

Every other section on this page follows from this one. Twilio does not sign your body, or your headers. It signs one string, built like this:

  1. Start with the full URL Twilio called: scheme, host, port if it was explicit, path, and the query string.
  2. Sort the POST parameter names in ASCII order.
  3. For each one, append the name immediately followed by its value. No separator, no equals sign, no encoding, nothing in between.
  4. HMAC-SHA1 the whole string with your Auth Token.
  5. Base64 the digest. That is X-Twilio-Signature.

The same thing with real values

URL called by Twilio
https://api.example.com/twilio/sms
POST parameters (form-urlencoded)
AccountSid=AC1234567890abcdef
Body=Hello
From=%2B15017122661
To=%2B15558675310
The signed string — 101 characters, concatenated
https://api.example.com/twilio/smsAccountSidAC1234567890abcdefBodyHelloFrom+15017122661To+15558675310
Auth Token (a throwaway, for the example)
12345678901234567890123456789012
base64(HMAC-SHA1(string, token))
REsmqJpWLoqe1Q/VJXTZjx4eviY=

Look at what is not in there. No &, no =, no percent-encoding: the value is +15017122661, not %2B15017122661. People who rebuild the params by splitting the raw body themselves usually forget to decode, and get a signature that is wrong for a reason no error message will ever mention.

Change one byte of those 101 characters and the signature changes entirely. That is the whole story: your code and Twilio have to build the exact same string, and every proxy, router and middleware in between is free to change it without telling you.

Four URLs, four signatures, one truth

Same Auth Token, same four params as above. Only the URL moves — and it is enough:

URL used to build the stringResulting signatureWhere it comes from
https://api.example.com/twilio/smsREsmqJpWLoqe1Q/VJXTZjx4eviY=What Twilio signs
http://api.example.com/twilio/smsNpKfrAiY/l6i2mXxfWlrjyRkytY=What your app sees behind SSL termination
https://api.example.com:443/twilio/smsvMbUG7KsMVUK80lzK6a+4IMFPqU=What a hand-built URL looks like
https://api.example.com/twilio/sms/8LRHF+mPXykd0Jij9WhgsI7agMs=What a router that normalizes paths gives you

Nothing in the failure tells you which of the four you are on. That is what the tool does: it computes all of them, up to sixteen URL forms, and compares each against the signature Twilio actually sent. The green line is the answer.

The 8 real causes

Ordered by how often they turn up in the Twilio community forum and in StackOverflow threads on this error. That ordering is our reading of them, not a measured statistic — treat it as a search order, not a fact.

#CauseThe symptom that gives it awayFix
1SSL termination (X-Forwarded-Proto)Passes with curl on localhost, fails the moment it sits behind a load balancer. Your log shows http://, the Console shows https://.Trust the proxy: app.set('trust proxy', true), ProxyFix(app.wsgi_app, x_proto=1), TrustedProxies on Laravel.
2Explicit port in the rebuilt URLYour log shows https://host:443/… — Twilio never writes an implicit port.Build from the Host / X-Forwarded-Host header. Do not concatenate hostname and port.
3Query string dropped or re-encodedOnly the URLs carrying ?params fail. Your framework returned the route pattern, not the raw target.Use the raw request target (req.originalUrl, request.url) and leave the encoding alone.
4Params altered before validationFails on some messages only — the ones with an empty field or trailing whitespace.Validate before any middleware touches the body. Python: parse_qs(…, keep_blank_values=True). Laravel: exclude the route from TrimStrings.
5JSON body validated as form dataConversations and Studio webhooks fail, plain SMS webhooks pass.Use validateRequestWithBody() and the bodySHA256 query param.
6Secondary Auth Token not promotedStarted failing right after a token rotation, on 100% of requests.Console → Account → Auth Token: a secondary token signs nothing until you promote it to Primary.
7Repeated params (SDK bug)Only group messaging and Conversations fail, and not reproducibly.twilio-node #722: params['MessagingBinding.Address'].sort() before validateRequest().
8Proxy rewrite changing the pathFails in prod only. Twilio calls /webhooks/sms, your app receives /sms.Validate against the public path, not the internal one left after the rewrite.

The repeated-param bug — it is not your fault

This one deserves its own section, because you can read Twilio's docs for an hour and never find it. Group messaging and Conversations send the same key several times:

The body Twilio POSTs
MessagingBinding.Address=%2B15558675310&MessagingBinding.Address=%2B15017122661

Twilio's server sorts the keys, then concatenates. Your params map holds an array. twilio-node does data += key + params[key], and JavaScript coerces that array to a string by joining it with commas — in arrival order. If your framework parsed the two addresses in an order different from the one Twilio's server used, the two strings differ and validation fails. Same token, same URL, same params. It fails anyway.

Two orders, two signatures — everything else identical
Arrival order: +15558675310 then +15017122661LJaNX3wj9/RRc4+GG1kwgPndM1c=
Sorted: +15017122661 then +15558675310A7jNj66u8x1pDaqy3CNma9Q0XvA=
The workaround
// twilio-node issue #722
// Repeated params reach you as an array; sort before validating.
if (Array.isArray(params['MessagingBinding.Address'])) {
  params['MessagingBinding.Address'].sort();
}
const valid = twilio.validateRequest(authToken, signature, url, params);

The tool tests both orders automatically. If the sorted line comes back green, you have found a bug in the SDK, not in your code — and you can stop rereading your handler.

Works locally, breaks in production

The single most common story, and it has one cause: in dev, your tunnel hands the request to your app on the same scheme Twilio used. In prod, Traefik, nginx or Cloudflare terminate TLS and forward plain HTTP on port 3000. Your framework reports what it sees on its socket — http:// — and signs a string Twilio never built.

Express
// Behind Traefik / nginx / Cloudflare
app.set('trust proxy', true);
// req.protocol now follows X-Forwarded-Proto
const url = req.protocol + '://' + req.get('host') + req.originalUrl;
Flask / Werkzeug
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
# request.url now rebuilds https://
validator.validate(request.url, request.form, signature)
Laravel
// app/Http/Middleware/TrustProxies.php
protected $proxies = '*';           // only if the proxy is yours
protected $headers = Request::HEADER_X_FORWARDED_ALL;

One line, three ecosystems. Once the proxy is trusted, the framework reads X-Forwarded-Proto and X-Forwarded-Host, rebuilds https://, and the signature matches. Only trust proxies you actually control: with trust proxy set to true, anything upstream can claim any scheme it likes.

JSON bodies: bodySHA256

Twilio webhooks are application/x-www-form-urlencoded by default — never JSON. But Conversations and Studio can send JSON, and then the rules change: the body is not in the params at all. Twilio hashes it with SHA-256, appends that hash to the URL as a bodySHA256 query parameter, and signs the URL with an empty param list.

Validating a JSON webhook (Node)
const valid = twilio.validateRequestWithBody(
  process.env.TWILIO_AUTH_TOKEN,
  req.headers['x-twilio-signature'],
  fullUrl,        // must still carry ?bodySHA256=…
  rawBody         // the raw string, NOT JSON.parse'd and re-stringified
);

Two traps here. Strip the query string from the URL and you also strip bodySHA256, so the check fails without ever mentioning the body. And if your framework parsed the JSON before you got to it, JSON.stringify() will not give you the original bytes back — key order and whitespace are gone. Capture the raw body. The JSON mode in the tool above shows both hashes side by side so you can see which of the two bit you.

Twilio facts worth having in mind

Details that decide whether the signature ever gets a chance to be checked:

  • HMAC-SHA1, not SHA256. Stripe, GitHub and Shopify all use SHA-256, so copying a working handler from one of them and swapping the header name produces a validator that never matches anything.
  • The payload is application/x-www-form-urlencoded by default, never JSON. If your framework only parses JSON bodies, your param map is empty and every signature fails.
  • Twilio waits 15 seconds for a voice webhook, 5 seconds on Conversations. Validate first, answer, then do the slow work — a signature check that runs after a database call can time out before it returns.
  • One retry by default, and up to five with connection overrides. So a bad signature is not one failed message, it is one message lost after a couple of attempts.
  • The Debugger keeps its logs for 30 days. That is where you find the URL Twilio actually called, which is the input this page needs.
  • On WebSocket connections the header arrives lowercase: x-twilio-signature. Match headers case-insensitively.

And Twilio error 57012?

Error 57012 (invalid signature) tells you the signature does not match the one Twilio computed, and points at the usual suspects: wrong token, wrong URL, params changed in flight, body hash missing. All true. But it stops exactly where debugging starts, because it cannot tell you which one applies to your request. Note as well that from Twilio's side, causes 1, 2, 3, 7 and 8 above are all just « wrong URL » — five different bugs, five different fixes, one error code. Splitting them is the point of this page.

When to use what — honestly

We are not the answer to everything here, and pretending otherwise would waste your afternoon.

Twilio's docs

You are writing the handler from scratch and want the reference algorithm and the SDK for your language. It is the source of truth — webhooks-security covers seven languages. It has no troubleshooting section at all, which is why you ended up here.

Hookdeck

Your problem is not one broken request, it is delivery: you want retries you control, a dead letter queue, timeouts beyond Twilio's 15 seconds, form-to-JSON transformation, or signature verification at the edge so your app never sees an unverified request. That is a platform, and it is a real one. Pay for it.

This page

One signature, right now, and you want to know which line of your code lies. Thirty seconds, nothing to install, no account. Then you go fix the proxy config and forget we exist. That is a fine outcome.

If webhooks are a permanent part of your stack rather than a fire you are putting out, our Relay tunnel forwards real Twilio traffic to localhost, and the Twilio webhook tester replays signed requests at your handler so you can check the fix without spending a real SMS.

Twilio Webhook TesterRelay

Häufige Fragen

Why does my Twilio signature fail behind ngrok or a proxy?

Because the proxy terminates TLS and forwards plain HTTP to your app. Twilio signed the https:// URL; your framework rebuilds http:// from its own socket. The strings differ, the HMAC differs. Trust the proxy so X-Forwarded-Proto is used to rebuild the URL, and it matches again.

Should the port be included in the URL?

Only if Twilio called it. Twilio signs the URL as configured, so a standard 443 never appears in the string. Add :443 yourself when rebuilding and you get a different signature. Rebuild the host from the Host header rather than concatenating hostname and port.

How do I validate a JSON body from Twilio?

With validateRequestWithBody() instead of validateRequest(). Twilio hashes the body with SHA-256, puts the hash in a bodySHA256 query parameter, and signs the URL with no params. You need the raw body bytes and the full URL including that query parameter.

Why does it work locally but not in production?

In dev your tunnel usually gives your app the same scheme Twilio used. In prod, Traefik, nginx or Cloudflare hand it plain HTTP on an internal port. Same code, different reconstructed URL, so the signature stops matching. It is the number-one cause on this page.

Does my Auth Token get sent to your servers?

No. The HMAC runs in your browser with WebCrypto, and the tool sends no request while you use it. Open your network tab and watch. Your Auth Token is a production secret — you should ask this of every site that asks for one, including ours.

Is the Twilio signature SHA-256, like Stripe's?

No, Twilio uses HMAC-SHA1 and base64-encodes it. Stripe, GitHub and Shopify use SHA-256. Adapting a working Stripe handler by renaming the header gives you a validator that rejects everything, and it is a common way to arrive at this error.

Twilio signature validation failed: find the cause · Webhook Toolkit