Webhook Debugging Guide
Twilio: "Twilio Request Validation Failed" behind ngrok (X-Twilio-Signature and the URL)
Short answer
Twilio's signature covers the URL, not just the body. X-Twilio-Signature is HMAC-SHA1, keyed with your account's Auth Token, over the full webhook URL (scheme, host, path and query string, exactly as configured in the Twilio console) with every POST field appended as name then value, sorted by name, no separators, base64-encoded. There is no timestamp.
Behind ngrok, Twilio signed https://a1b2c3d4.ngrok-free.app/sms. Your app then rebuilds the URL from what it can see, and what it sees is a plain HTTP connection from the ngrok agent on your own machine. Express's req.protocol says http unless trust proxy is set; Flask's request.url is the same story. Twilio's Express middleware answers 403 Twilio Request Validation Failed., the Flask decorator from Twilio's tutorial calls abort(403), and the Auth Token was right the whole time. Twilio's own tutorials say so: the validator "may fail locally when you use ngrok or in production if your stack terminates SSL connections upstream from your app."
The fix is to validate against the URL Twilio actually used, not the one your framework reconstructs.
What the signature looks like for each URL
Below, twilio 9.11.2's RequestValidator checks one SMS webhook (Auth Token 12345, fields Body, From, MessageSid, To) signed for the public ngrok URL, against the URL variants an app behind the tunnel typically reconstructs:
https://a1b2c3d4.ngrok-free.app/sms valid=True
http://a1b2c3d4.ngrok-free.app/sms valid=False <- req.protocol without trust proxy
http://localhost:3000/sms valid=False <- Host rewritten, or hard-coded
https://a1b2c3d4.ngrok-free.app/sms/ valid=False <- trailing slash from a rewrite
https://a1b2c3d4.ngrok-free.app:443/sms valid=True <- the port is forgiven
The same script reproduces the test vector from Twilio's Python library (https://mycompany.com/myapp.php?foo=1&bar=2, token 12345, the five call fields from its unit tests) as RSOYDt4T1cUTdK1PDd93/VVr8B8= with both the library and 10 lines of standard-library code, so the algorithm above is the whole algorithm. The worked example in Twilio's security docs page does not reproduce; the page says it is "for illustrative purposes only".
The :443 row passes because Twilio's helper libraries compute the signature twice, with and without the scheme's default port, and accept either. Scheme, host, path and query get no such leniency.
Node (Express)
Twilio's middleware builds the URL as protocol + host + req.originalUrl from the request unless you tell it otherwise. Its url option is that override:
import express from 'express';
import twilio from 'twilio';
const app = express();
app.use(express.urlencoded({ extended: false })); // Twilio posts form fields
// The URL pasted into the Twilio console, exactly: scheme, host, path, query.
const WEBHOOK_URL = process.env.TWILIO_WEBHOOK_URL; // https://a1b2c3d4.ngrok-free.app/sms
app.post('/sms', twilio.webhook({ url: WEBHOOK_URL }), (req, res) => {
res.type('text/xml').send('<Response><Message>Got it</Message></Response>');
});
app.listen(3000);
The middleware reads the token from TWILIO_AUTH_TOKEN (or an authToken option), returns 400 when the header is missing entirely and 403 when it doesn't match. Without the url option, app.set('trust proxy', 'loopback') also works for ngrok specifically: the agent connects from 127.0.0.1 and sends X-Forwarded-Proto: https, which Express then reflects in req.protocol. The explicit URL is the version that keeps working when a teammate runs a different tunnel. To check by hand: twilio.validateRequest(authToken, req.get('X-Twilio-Signature'), WEBHOOK_URL, req.body) returns a boolean.
Python (Flask)
import os
from flask import Flask, request, abort
from twilio.request_validator import RequestValidator
app = Flask(__name__)
validator = RequestValidator(os.environ["TWILIO_AUTH_TOKEN"])
WEBHOOK_URL = os.environ["TWILIO_WEBHOOK_URL"] # https://a1b2c3d4.ngrok-free.app/sms
@app.post("/sms")
def sms():
if not validator.validate(WEBHOOK_URL, request.form, request.headers.get("X-Twilio-Signature", "")):
abort(403)
return "<Response><Message>Got it</Message></Response>", 200, {"Content-Type": "text/xml"}
Twilio's Flask tutorial passes request.url here, which is where the ngrok mismatch comes from. Its suggested workaround is to register the webhook with http:// instead of https:// so the reconstructed scheme happens to match; passing the configured URL is the same fix without downgrading the webhook.
Other reasons it fails with the right token
- Trailing slash or index rewrite.
/smsand/sms/sign differently (fourth row above). Twilio's docs call out URL rewriting that adds a slash or turns/into/index.php. - The query string got decoded. Query parameters are part of the signed URL as sent. Twilio's docs: "If you decode or re-encode the URL, signature validation fails." Flask can unescape the query string; keep the configured URL as a literal and the problem goes away.
- A framework trimmed the fields. Laravel's
TrimStringsmiddleware is Twilio's own example; aBodywith a trailing space signs differently once trimmed. - Query parameters passed as params. They belong in the URL argument only. Passing them a second time in
paramschanges the signed string. - A secondary Auth Token. Twilio signs with the primary token until a secondary one is promoted.
- JSON webhooks. When Twilio posts JSON it signs the URL with a
bodySHA256query parameter appended (the hex SHA-256 of the raw body) and no form fields. UsevalidateRequestWithBodyin Node, or pass the raw body string asparamsin Python, so the library checks the hash.
Where WebhookMon fits
WebhookMon's relay has the same URL question as ngrok, with one difference: the URL Twilio signed is the relay URL you pasted into the console, https://hooks.getwebhookmon.com/e/<id> plus any path you added, and the app forwards each event to the local target you configured, stripping Host and every X-Forwarded-* header on the way. Your handler therefore sees http://localhost:3000/webhooks, and the url option above should hold the relay URL. WebhookMon has no Twilio signature verdict (its verdicts cover Stripe, Polar, GitHub, Shopify and Slack, plus Standard Webhooks senders), but every Twilio event shows up with its exact form body and its X-Twilio-Signature header, so a copy of the URL and the fields into the script above tells you in seconds whether the URL or the token is wrong. Replays are sent byte for byte, and since nothing time-based is in the signature, a Twilio event captured last week still validates against the same URL today. If you want a Twilio verdict, ask for it; requests decide what gets built next.