Twilio Webhook Forgery Is Real: Validate X-Twilio-Signature Before You Trust Any Callback
Anyone who discovers your Twilio webhook URL can forge inbound messages or status callbacks. Validate the X-Twilio-Signature HMAC over the exact public URL and sorted parameters, capture the raw body before middleware consumes it, and deduplicate on MessageSid or CallSid to survive replays.
31 Jul 2026, 01:22 UTC

Twilio delivers inbound SMS messages, voice status callbacks, and other events as ordinary HTTP POST requests to a URL you configure. That URL is not a secret. If an attacker discovers it, they can POST fabricated MessageSid values, fake delivery statuses, or spoofed caller IDs directly to your endpoint. The useful takeaway is simple: treat every Twilio callback as untrusted input until the X-Twilio-Signature header validates against your Auth Token.
What Twilio Actually Signs
For form-encoded POSTs, Twilio builds a signature string by concatenating the exact public URL it used—scheme, host, path, and query string—with every parameter name and value in ascending key order. It then computes an HMAC-SHA1 digest keyed by your Auth Token and base64-encodes the result. Your job is to reproduce that string from the request you actually received and compare the base64 output to the X-Twilio-Signature header using a constant-time check.
HMAC-SHA1 appears here for Twilio interoperability, not as a general cryptographic endorsement. The Auth Token remains a long-lived secret: if it leaks, rotate it immediately.
A Worked Validation Example
The most common failure mode is middleware ordering. If express.urlencoded() or express.json() consumes the request stream before your validator runs, the raw bytes are gone and signature verification will silently fail. Capture the raw body first.
Prefer Twilio's official helper-library request validator over hand-rolled code. The conceptual Node.js flow below shows what the helper does internally so you can debug mismatches. Run this inside your webhook route handler, not in a separate script.
const crypto = require('crypto');
// Conceptual illustration—prefer the official twilio helper library in production.
function computeTwilioSignature(authToken, url, params) {
const sortedKeys = Object.keys(params).sort();
let data = url;
for (const key of sortedKeys) {
data += key + params[key];
}
return crypto.createHmac('sha1', authToken).update(data).digest('base64');
}
// In an Express handler with raw body captured:
// const isValid = crypto.timingSafeEqual(
// Buffer.from(computeTwilioSignature(authToken, publicUrl, parsedParams)),
// Buffer.from(req.headers['x-twilio-signature'])
// );
Required permissions and inputs: the handler needs read access to the TWILIO_AUTH_TOKEN environment variable and the raw request bytes. The publicUrl must match the webhook URL configured in the Twilio Console exactly. Do not rebuild it from X-Forwarded-* headers unless you fully control the proxy chain; a proxy that rewrites the host, scheme, or port will produce a different signature.
| Input | Signed by Twilio | Common mistake |
|---|---|---|
| URL | Public URL from Twilio config | Internal container URL or proxy-rewritten host |
| Body | Raw form-encoded bytes | Re-parsed or re-encoded object from middleware |
| Parameter order | Ascending key sort | Insertion order from the HTTP parser |
Expected check: log the computed signature beside the X-Twilio-Signature header for a real inbound message. They should match byte-for-byte. If they differ, mutate one parameter value and confirm your handler returns 403 before any business logic runs.
The Fidelity Trade-Off and Replay Limits
Validation proves origin and integrity, but it is brittle. The signature covers the exact public URL and the exact bytes. A TLS-terminating load balancer that forwards over HTTP, a framework that re-encodes + as a space, or a JSON body parser that reads the stream first will all break verification. The trade-off is operational: you must preserve raw fidelity at the edge of your application. That often means placing the validator before general body-parsing middleware and pinning the expected public base URL in configuration rather than deriving it per-request.
A valid signature also does not prove freshness. An attacker who captures a legitimate webhook can replay it later, and the signature will still verify. Pair validation with idempotency on the unique event identifier Twilio provides—MessageSid for messaging events or CallSid for voice events. Store that identifier in a short-lived deduplication cache and ignore duplicates. This also protects against Twilio's own retry behavior when your endpoint is briefly unavailable.
Ship It Safely
- Fail closed. Any error during validation—missing header, malformed body, token misconfiguration—should return
403and stop processing. - Log the mismatch reason, never the Auth Token. Record whether the URL, parameter set, or header was the likely culprit so you can debug proxy or middleware issues.
- Keep TLS. Signature validation attests origin and integrity; it does not encrypt the payload or replace network-level authorization.
- Unit-test deterministically. Use a known test Auth Token, a fixed URL, and a known parameter set so the expected signature is reproducible in CI.
Signature validation is a gate, not a complete security model. Start by capturing the raw body before any parser touches it, validate against the exact public URL configured in Twilio, and deduplicate on MessageSid or CallSid. Then send a real test message to your Twilio number and confirm the handler accepts the valid callback and rejects a mutated copy.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.