Verify a Twilio webhook signature

base64(HMAC-SHA1(auth token, full URL + sorted POST names and values))

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 (To before body). 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.

Try it in the HMAC Generator

Signed as the UTF-8 bytes of what's pasted, spaces included, with every line break written as the one chosen. This box keeps line breaks as LF whatever was pasted, so pick CRLF for a body sent with CRLF line endings.

The key never leaves your browser: the signature is worked out here in the page, and nothing is sent anywhere.

Algorithm
Written as
Signature · SHA-1

L/OH5YylLD5NRKLltdqwSvS0BnU=

123 bytes of message, 5 of key.

Matches — this message, signed with this key.

How webhook signatures are checked

Both services below sign exactly the way this tool does, so the value after the prefix is an HMAC you can check here.

ServiceWhat it sendsWhat the signature is made of
GitHubX-Hub-Signature-256: sha256=<hex>HMAC-SHA256 of the raw request body, keyed with the webhook secret.
StripeStripe-Signature: t=<time>,v1=<hex>HMAC-SHA256 of <time>.<raw body>, keyed with the signing secret, whsec_… and all.

GitHub: paste the raw body as the message, the secret as the key, and what follows sha256= into Check a signature. Stripe: sign t.body — the timestamp from the header, a dot, then the raw body — with the whole signing secret as the key, and compare against v1=.

Runs in your browser. Nothing you enter is sent anywhere.