X-Twilio-Signature is the HMAC-SHA1 of a string Twilio builds from the request: the full URL it called, then each POST parameter’s name and value, sorted by name, with no delimiters. The key is your account’s auth token, and the result is written in base64. Twilio’s docs work through a call to https://example.com/myapp.php?foo=1&bar=2 with five parameters and the auth token 12345:
const crypto = require('node:crypto');
function twilioSignature(authToken, url, params) {
const data = Object.keys(params).sort().reduce((text, key) => text + key + params[key], url);
return crypto.createHmac('sha1', authToken).update(data).digest('base64');
}
twilioSignature('12345', 'https://example.com/myapp.php?foo=1&bar=2', {
Digits: '1234', To: '+18005551212', From: '+14158675310', Caller: '+14158675310', CallSid: 'CA1234567890ABCDE',
});
// 'L/OH5YylLD5NRKLltdqwSvS0BnU='
The signed string is the tool’s message below, and the tool shows the same L/OH5YylLD5NRKLltdqwSvS0BnU= with SHA-1 and Base64. The values are form-decoded: the body carried To=%2B18005551212, and the string has +18005551212.
What goes into the string
- The URL exactly as Twilio called it: scheme, host, path and query string, neither decoded nor re-encoded. Query parameters stay in the URL and don’t join the sorted list. Twilio’s docs add that it drops any username and password from the URL, keeps the port for SMS callbacks, and drops it for voice callbacks over HTTPS.
- Every POST parameter, sorted case-sensitively (
Tobeforebody). Twilio adds parameters without notice, so never pass a fixed list. One with an empty value still adds its name. - JSON bodies work differently: Twilio appends
bodySHA256, the hex SHA-256 of the raw body, to the URL and signs the URL with no parameters.
Verify it in Node
Twilio’s docs recommend the SDK’s twilio.validateRequest(authToken, signature, url, params), which returns true for the example above. By hand, compare in constant time, checking the length first because timingSafeEqual throws on a mismatch (the SDK does the same):
function verifyTwilio(authToken, url, params, header) {
const expected = Buffer.from(twilioSignature(authToken, url, params));
const received = Buffer.from(header ?? '');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
For form posts, Twilio signs names and values, not body bytes, so express.urlencoded is the right parser. The trap is the URL: behind a proxy that ends TLS, req.protocol is http, and http://example.com/… doesn’t verify. Write the public scheme yourself:
const express = require('express');
const twilio = require('twilio');
const app = express();
app.post('/myapp.php', express.urlencoded({ extended: false }), (req, res) => {
const url = `https://${req.get('host')}${req.originalUrl}`;
if (!verifyTwilio(process.env.TWILIO_AUTH_TOKEN, url, req.body, req.get('X-Twilio-Signature'))) {
return res.sendStatus(403);
}
res.type('text/xml').send('<Response/>');
});
// JSON webhooks: the SDK's middleware hashes req.rawBody, so keep the bytes from express.json.
app.post('/events', express.json({ verify: (req, res, buf) => { req.rawBody = buf.toString('utf8'); } }), twilio.webhook({ protocol: 'https' }), (req, res) => {
res.send(`ok ${req.body.CallSid}`);
});
Without the verify callback, twilio.webhook hashes {} in place of the body and answers 403 Twilio Request Validation Failed.
Verify it in Python
import base64
import hashlib
import hmac
def twilio_signature(auth_token: str, url: str, params: dict[str, str]) -> str:
data = url + "".join(key + params[key] for key in sorted(params))
return base64.b64encode(hmac.new(auth_token.encode(), data.encode(), hashlib.sha1).digest()).decode()
def verify_twilio(auth_token: str, url: str, params: dict[str, str], header: str | None) -> bool:
return hmac.compare_digest(twilio_signature(auth_token, url, params).encode(), (header or "").encode())
The SDK’s version is RequestValidator(auth_token).validate(url, params, signature). In Flask, request.form holds the parameters and request.url the URL; behind a proxy, wrap the app in Werkzeug’s ProxyFix so the URL has the public scheme and host. In FastAPI, parse await request.body() with keep_blank_values=True. Without it, parse_qsl drops a parameter like SpeechResult=, and the signature fails:
import os
from urllib.parse import parse_qsl
from fastapi import FastAPI, HTTPException, Request
from flask import Flask, abort, request
from werkzeug.middleware.proxy_fix import ProxyFix
TOKEN = os.environ["TWILIO_AUTH_TOKEN"]
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
@app.post("/myapp.php")
def voice():
if not verify_twilio(TOKEN, request.url, request.form.to_dict(), request.headers.get("X-Twilio-Signature")):
abort(403)
return "<Response/>", 200, {"Content-Type": "text/xml"}
api = FastAPI()
@api.post("/myapp.php")
async def voice_fastapi(request: Request):
params = dict(parse_qsl((await request.body()).decode(), keep_blank_values=True))
if not verify_twilio(TOKEN, str(request.url), params, request.headers.get("X-Twilio-Signature")):
raise HTTPException(status_code=403)
return {"ok": True}
Twilio signs no timestamp, so there’s no tolerance window to check. Its docs note that HMAC-SHA1 isn’t weakened by SHA-1’s collision attacks, because the auth token is the key.