Signature Twilio invalide — trouvez pourquoi
Vous avez un header X-Twilio-Signature, votre code dit qu'il ne matche pas, et la doc Twilio vous répond par un SDK à installer. Ça ne vous dit pas laquelle de vos trois entrées est fausse : l'URL, les params ou le token. Collez-les ci-dessous : on recalcule la signature pour chaque forme d'URL plausible et on vous montre celle qui matche. La ligne qui matche est le diagnostic.
En résumé : une signature Twilio échoue presque toujours parce que l'URL reconstruite par votre code n'est pas identique octet pour octet à celle que Twilio a appelée — le plus souvent http:// au lieu de https:// derrière un reverse proxy. L'algorithme est rarement en cause.
Vérifier une signature refusée par votre code
La chaîne exacte que Twilio signe
Tout le reste de la page découle d'ici. Twilio ne signe ni votre corps de requête, ni vos headers. Il signe une chaîne, construite comme ceci :
- Partir de l'URL complète appelée par Twilio : schéma, hôte, port s'il était explicite, chemin, et la query string.
- Trier les noms des paramètres POST par ordre ASCII.
- Pour chacun, coller le nom suivi immédiatement de sa valeur. Aucun séparateur, aucun signe égal, aucun encodage, rien entre les deux.
- Passer toute la chaîne en HMAC-SHA1 avec votre Auth Token.
- Encoder le digest en base64. C'est X-Twilio-Signature.
La même chose avec de vraies valeurs
https://api.example.com/twilio/sms
AccountSid=AC1234567890abcdef Body=Hello From=%2B15017122661 To=%2B15558675310
https://api.example.com/twilio/smsAccountSidAC1234567890abcdefBodyHelloFrom+15017122661To+15558675310
12345678901234567890123456789012
REsmqJpWLoqe1Q/VJXTZjx4eviY=
Regardez ce qui n'y est pas. Pas de &, pas de =, pas de pourcent-encodage : la valeur est +15017122661, pas %2B15017122661. Ceux qui reconstruisent les params en découpant le corps brut à la main oublient en général de décoder, et obtiennent une signature fausse pour une raison qu'aucun message d'erreur ne mentionnera jamais.
Changez un octet de ces 101 caractères et la signature change entièrement. Toute l'affaire est là : votre code et Twilio doivent construire exactement la même chaîne, et chaque proxy, routeur ou middleware entre les deux a le droit de la modifier sans vous prévenir.
Quatre URL, quatre signatures, une seule vraie
Même Auth Token, mêmes quatre params que ci-dessus. Seule l'URL bouge — et ça suffit :
| URL utilisée pour construire la chaîne | Signature obtenue | D'où ça vient |
|---|---|---|
| https://api.example.com/twilio/sms | REsmqJpWLoqe1Q/VJXTZjx4eviY= | Ce que Twilio signe |
| http://api.example.com/twilio/sms | NpKfrAiY/l6i2mXxfWlrjyRkytY= | Ce que voit votre app derrière une terminaison SSL |
| https://api.example.com:443/twilio/sms | vMbUG7KsMVUK80lzK6a+4IMFPqU= | Ce que donne une URL reconstruite à la main |
| https://api.example.com/twilio/sms/ | 8LRHF+mPXykd0Jij9WhgsI7agMs= | Ce que donne un routeur qui normalise les chemins |
Rien dans l'échec ne vous dit sur laquelle des quatre vous êtes. C'est le travail de l'outil : il les calcule toutes, jusqu'à seize formes d'URL, et compare chacune à la signature réellement envoyée par Twilio. La ligne verte est la réponse.
Les 8 causes réelles
Classées selon leur fréquence d'apparition sur le forum communautaire Twilio et dans les fils StackOverflow sur cette erreur. Ce classement est notre lecture, pas une statistique mesurée : prenez-le comme un ordre de recherche, pas comme un fait.
| # | Cause | Le symptôme qui la trahit | Correction |
|---|---|---|---|
| 1 | Terminaison SSL (X-Forwarded-Proto) | Passe en curl sur localhost, échoue dès que ça passe derrière un load balancer. Votre log affiche http://, la Console affiche https://. | Faire confiance au proxy : app.set('trust proxy', true), ProxyFix(app.wsgi_app, x_proto=1), TrustedProxies sur Laravel. |
| 2 | Port explicite dans l'URL reconstruite | Votre log affiche https://host:443/… — Twilio n'écrit jamais un port implicite. | Reconstruire depuis le header Host / X-Forwarded-Host. Ne pas concaténer hostname et port. |
| 3 | Query string perdue ou ré-encodée | Seules les URL qui portent des ?params échouent. Votre framework a rendu le motif de route, pas la cible brute. | Utiliser la cible brute (req.originalUrl, request.url) et ne pas toucher à l'encodage. |
| 4 | Params modifiés avant la validation | N'échoue que sur certains messages — ceux avec un champ vide ou une espace en fin de valeur. | Valider avant que le moindre middleware ne touche au corps. Python : parse_qs(…, keep_blank_values=True). Laravel : exclure la route de TrimStrings. |
| 5 | Corps JSON validé comme du form data | Les webhooks Conversations et Studio échouent, les webhooks SMS passent. | Utiliser validateRequestWithBody() et le paramètre de query bodySHA256. |
| 6 | Auth Token secondaire non promu | A commencé à échouer juste après une rotation de token, sur 100 % des requêtes. | Console → Account → Auth Token : un token secondaire ne signe rien tant qu'il n'est pas promu Primary. |
| 7 | Params répétés (bug du SDK) | Seuls le group messaging et Conversations échouent, et pas de façon reproductible. | twilio-node #722 : params['MessagingBinding.Address'].sort() avant validateRequest(). |
| 8 | Rewrite du proxy qui change le chemin | Échoue en prod seulement. Twilio appelle /webhooks/sms, votre app reçoit /sms. | Valider sur le chemin public, pas sur le chemin interne laissé par le rewrite. |
Le bug des params répétés — ce n'est pas votre faute
Celui-là mérite sa section, parce que vous pouvez lire la doc Twilio pendant une heure sans jamais le trouver. Le group messaging et Conversations envoient la même clé plusieurs fois :
MessagingBinding.Address=%2B15558675310&MessagingBinding.Address=%2B15017122661
Le serveur Twilio trie les clés puis concatène. Votre map de params, elle, contient un tableau. twilio-node fait data += key + params[key], et JavaScript convertit ce tableau en chaîne en le joignant par des virgules — dans l'ordre d'arrivée. Si votre framework a parsé les deux adresses dans un ordre différent de celui du serveur Twilio, les deux chaînes divergent et la validation échoue. Même token, même URL, mêmes params. Ça échoue quand même.
| Deux ordres, deux signatures — tout le reste identique | |
|---|---|
| Ordre d'arrivée : +15558675310 puis +15017122661 | LJaNX3wj9/RRc4+GG1kwgPndM1c= |
| Trié : +15017122661 puis +15558675310 | A7jNj66u8x1pDaqy3CNma9Q0XvA= |
// twilio-node issue #722
// Les params répétés arrivent en tableau ; trier avant de valider.
if (Array.isArray(params['MessagingBinding.Address'])) {
params['MessagingBinding.Address'].sort();
}
const valid = twilio.validateRequest(authToken, signature, url, params);L'outil teste les deux ordres automatiquement. Si la ligne triée ressort en vert, vous avez trouvé un bug du SDK et pas de votre code — et vous pouvez arrêter de relire votre handler.
Ça marche en local, ça casse en prod
L'histoire la plus fréquente, et elle a une seule cause : en dev, votre tunnel remet la requête à votre app sur le même schéma que celui utilisé par Twilio. En prod, Traefik, nginx ou Cloudflare terminent le TLS et transmettent du HTTP simple sur le port 3000. Votre framework rapporte ce qu'il voit sur sa socket — http:// — et signe une chaîne que Twilio n'a jamais construite.
// Derrière Traefik / nginx / Cloudflare
app.set('trust proxy', true);
// req.protocol suit maintenant X-Forwarded-Proto
const url = req.protocol + '://' + req.get('host') + req.originalUrl;from werkzeug.middleware.proxy_fix import ProxyFix app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) # request.url reconstruit maintenant du https:// validator.validate(request.url, request.form, signature)
// app/Http/Middleware/TrustProxies.php protected $proxies = '*'; // seulement si le proxy est le vôtre protected $headers = Request::HEADER_X_FORWARDED_ALL;
Une ligne, trois écosystèmes. Une fois le proxy déclaré de confiance, le framework lit X-Forwarded-Proto et X-Forwarded-Host, reconstruit https://, et la signature matche. Ne faites confiance qu'aux proxies que vous contrôlez vraiment : avec trust proxy à true, n'importe quoi en amont peut annoncer le schéma qu'il veut.
Corps JSON : bodySHA256
Les webhooks Twilio sont en application/x-www-form-urlencoded par défaut — jamais en JSON. Mais Conversations et Studio peuvent envoyer du JSON, et là les règles changent : le corps n'est plus du tout dans les params. Twilio le hashe en SHA-256, colle ce hash dans l'URL sous forme d'un paramètre de query bodySHA256, et signe l'URL avec une liste de params vide.
const valid = twilio.validateRequestWithBody( process.env.TWILIO_AUTH_TOKEN, req.headers['x-twilio-signature'], fullUrl, // doit toujours porter ?bodySHA256=… rawBody // la chaîne brute, PAS un JSON.parse puis re-stringify );
Deux pièges. Retirez la query string de l'URL et vous retirez aussi bodySHA256 : la vérification échoue sans jamais parler du corps. Et si votre framework a parsé le JSON avant vous, JSON.stringify() ne vous rendra pas les octets d'origine — l'ordre des clés et les espaces ont disparu. Capturez le corps brut. Le mode JSON de l'outil affiche les deux hashs côte à côte, vous verrez lequel des deux vous a mordu.
Les faits Twilio à avoir en tête
Des détails qui décident si la signature a seulement une chance d'être vérifiée :
- HMAC-SHA1, pas SHA256. Stripe, GitHub et Shopify utilisent tous SHA-256 : copier un handler qui marche chez l'un et changer le nom du header donne un validateur qui ne matchera jamais rien.
- Le payload est en
application/x-www-form-urlencodedpar défaut, jamais en JSON. Si votre framework ne parse que les corps JSON, votre map de params est vide et toutes les signatures échouent. - Twilio attend 15 secondes pour un webhook voix, 5 secondes sur Conversations. Validez d'abord, répondez, puis faites le travail lent — une vérification de signature placée après un appel base de données peut expirer avant de rendre la main.
- Un retry par défaut, jusqu'à cinq avec les connection overrides. Une signature fausse, ce n'est donc pas un message en échec : c'est un message perdu après deux ou trois tentatives.
- Le Debugger garde ses logs 30 jours. C'est là que vous trouverez l'URL réellement appelée par Twilio, celle dont cette page a besoin.
- Sur les connexions WebSocket, le header arrive en minuscules :
x-twilio-signature. Comparez les noms de headers sans tenir compte de la casse.
Et l'erreur Twilio 57012 ?
L'erreur 57012 (signature invalide) vous dit que la signature ne correspond pas à celle calculée par Twilio, et pointe les suspects habituels : mauvais token, mauvaise URL, params modifiés en route, hash du corps manquant. Tout est vrai. Mais elle s'arrête exactement là où le débogage commence, puisqu'elle ne peut pas vous dire lequel s'applique à votre requête. Notez aussi que, vu de Twilio, les causes 1, 2, 3, 7 et 8 ci-dessus sont toutes « mauvaise URL » : cinq bugs différents, cinq corrections différentes, un seul code d'erreur. Les séparer, c'est l'objet de cette page.
Quoi utiliser, honnêtement
On n'est pas la réponse à tout ici, et prétendre le contraire vous ferait perdre l'après-midi.
Vous écrivez le handler de zéro et vous voulez l'algorithme de référence et le SDK de votre langage. C'est la source de vérité — webhooks-security couvre sept langages. Elle n'a aucune section troubleshooting, et c'est pour ça que vous avez atterri ici.
Votre problème n'est pas une requête cassée, c'est la livraison : vous voulez des retries que vous pilotez, une dead letter queue, des timeouts au-delà des 15 s de Twilio, la transformation form → JSON, ou la vérification de signature à l'edge pour que votre app ne voie jamais une requête non vérifiée. C'est une plateforme, et une vraie. Payez-la.
Une signature, maintenant, et vous voulez savoir quelle ligne de votre code ment. Trente secondes, rien à installer, pas de compte. Ensuite vous allez corriger la config de votre proxy et vous nous oubliez. Ça nous va très bien.
Si les webhooks sont une brique permanente de votre stack et pas un incendie que vous éteignez, notre tunnel Relay transmet le vrai trafic Twilio vers localhost, et le testeur de webhook Twilio rejoue des requêtes signées vers votre handler pour vérifier le correctif sans dépenser un vrai SMS.
Questions fréquentes
Pourquoi ma signature Twilio échoue derrière ngrok ou un proxy ?
Parce que le proxy termine le TLS et transmet du HTTP simple à votre app. Twilio a signé l'URL en https:// ; votre framework reconstruit du http:// depuis sa propre socket. Les chaînes diffèrent, le HMAC diffère. Déclarez le proxy de confiance pour que X-Forwarded-Proto serve à reconstruire l'URL.
Faut-il inclure le port dans l'URL ?
Seulement si Twilio l'a appelé. Twilio signe l'URL telle que configurée : un 443 standard n'apparaît donc jamais dans la chaîne. Ajoutez :443 vous-même à la reconstruction et vous obtenez une autre signature. Reconstruisez l'hôte depuis le header Host plutôt que de concaténer hostname et port.
Comment valider un corps JSON envoyé par Twilio ?
Avec validateRequestWithBody() au lieu de validateRequest(). Twilio hashe le corps en SHA-256, met le hash dans un paramètre de query bodySHA256, et signe l'URL sans params. Il vous faut les octets bruts du corps et l'URL complète, ce paramètre compris.
Pourquoi ça marche en local et pas en prod ?
En dev, votre tunnel donne en général à votre app le même schéma que celui utilisé par Twilio. En prod, Traefik, nginx ou Cloudflare lui passent du HTTP simple sur un port interne. Même code, URL reconstruite différente, la signature ne matche plus. C'est la cause numéro un de cette page.
Mon Auth Token part-il sur vos serveurs ?
Non. Le HMAC tourne dans votre navigateur via WebCrypto, et l'outil n'envoie aucune requête pendant que vous l'utilisez. Ouvrez l'onglet réseau et regardez. Votre Auth Token est un secret de production : posez cette question à tout site qui vous en réclame un, y compris le nôtre.
La signature Twilio est-elle en SHA-256 comme celle de Stripe ?
Non, Twilio utilise HMAC-SHA1 et l'encode en base64. Stripe, GitHub et Shopify utilisent SHA-256. Adapter un handler Stripe qui marche en renommant juste le header donne un validateur qui refuse tout, et c'est une façon courante d'arriver sur cette erreur.