Overview of the Issue
validateRequest() silently returns false for webhook requests that were validly signed by Twilio, when the URL contains characters that the WHATWG new URL() parser percent-encodes during normalization.
The confirmed case is an apostrophe (' → %27), but any character that new URL() encodes and Twilio’s backend does not should produce the same failure.
No error is thrown. The request is rejected as if the signature were invalid.
Motivation / Use Case
A customer can implement validateRequest() exactly as documented and still have webhook validation fail in production when a query parameter contains an apostrophe or another affected character.
Twilio successfully delivers the webhook and signs the original URL. On the customer side, validateRequest() returns false, so the request is rejected as invalid.
twilio-node Version(s)
Confirmed regression introduced in v5.0.4 and continues through to the latest version. Working correctly in v5.0.3 and earlier.
In v5.0.3, validateRequest() used url-parse for port normalization. url-parse did not re-serialize query characters, so the URL string passed into HMAC matched what Twilio signed.
In v5.0.4, url-parse was replaced with the built-in new URL(). Unlike url-parse, new URL() normalizes the full URL during parsing, including percent-encoding characters such as ' to %27. Twilio’s backend did not encode those characters in the signed URL, so the HMAC input changes and validation fails.
See: v5.0.4 changelog
Reproduce the Error
const twilio = require('twilio');
const authToken = '<your-auth-token>';
const url = "https://example.twil.io/webhook?name=William+O'hara";
const incomingSignature = twilio.getExpectedTwilioSignature(authToken, url, {});
// getExpectedTwilioSignature correctly matches
console.log(twilio.getExpectedTwilioSignature(authToken, url, {}));
// validateRequest rejects the same inputs
console.log(twilio.validateRequest(authToken, incomingSignature, url, {}));
// → false (should be true)
Two SDK functions given the same inputs produce different results.
Root Cause
In lib/webhooks/webhooks.js, validateRequest() parses the URL with new URL(url). Both urlWithPort and urlWithoutPort are derived from that normalized object, so the HMAC is computed from a modified URL string rather than the original string Twilio signed.
const urlObject = new URL(url); // ' becomes %27 here
const urlWithPort = addPort(urlObject); // derived from normalized object
const urlWithoutPort = removePort(urlObject); // derived from normalized object
getExpectedTwilioSignature() hashes the original URL string as-is. That is why it correctly reproduces the same signature that validateRequest() rejects.
Related Issues
No prior issues found for this exact behavior. (Sorry if I missed it.)
Suggest a Fix
One option is to use new URL() only to extract protocol and hostname, then build the port-normalized variants from the original URL string so the query string is never re-encoded:
const urlObject = new URL(url);
const origin = url.match(/^(https?:\/\/[^/?#]*)/)?.[1] ?? "";
const rest = url.slice(origin.length);
const defaultPort = urlObject.protocol === "https:" ? "443" : "80";
const hostNoPort = urlObject.hostname;
const urlWithPort = `${urlObject.protocol}//${hostNoPort}:${defaultPort}${rest}`;
const urlWithoutPort = `${urlObject.protocol}//${hostNoPort}${rest}`;
Another option is to reintroduce url-parse, which handled port normalization without altering query-string serialization.
Overview of the Issue
validateRequest()silently returnsfalsefor webhook requests that were validly signed by Twilio, when the URL contains characters that the WHATWGnew URL()parser percent-encodes during normalization.The confirmed case is an apostrophe (
'→%27), but any character thatnew URL()encodes and Twilio’s backend does not should produce the same failure.No error is thrown. The request is rejected as if the signature were invalid.
Motivation / Use Case
A customer can implement
validateRequest()exactly as documented and still have webhook validation fail in production when a query parameter contains an apostrophe or another affected character.Twilio successfully delivers the webhook and signs the original URL. On the customer side,
validateRequest()returnsfalse, so the request is rejected as invalid.twilio-node Version(s)
Confirmed regression introduced in v5.0.4 and continues through to the latest version. Working correctly in v5.0.3 and earlier.
In v5.0.3,
validateRequest()usedurl-parsefor port normalization.url-parsedid not re-serialize query characters, so the URL string passed into HMAC matched what Twilio signed.In v5.0.4,
url-parsewas replaced with the built-innew URL(). Unlikeurl-parse,new URL()normalizes the full URL during parsing, including percent-encoding characters such as'to%27. Twilio’s backend did not encode those characters in the signed URL, so the HMAC input changes and validation fails.See: v5.0.4 changelog
Reproduce the Error
Two SDK functions given the same inputs produce different results.
Root Cause
In
lib/webhooks/webhooks.js,validateRequest()parses the URL withnew URL(url). BothurlWithPortandurlWithoutPortare derived from that normalized object, so the HMAC is computed from a modified URL string rather than the original string Twilio signed.getExpectedTwilioSignature()hashes the original URL string as-is. That is why it correctly reproduces the same signature thatvalidateRequest()rejects.Related Issues
No prior issues found for this exact behavior. (Sorry if I missed it.)
Suggest a Fix
One option is to use
new URL()only to extract protocol and hostname, then build the port-normalized variants from the original URL string so the query string is never re-encoded:Another option is to reintroduce
url-parse, which handled port normalization without altering query-string serialization.