Twilio-handtekeningvalidatie mislukt — ontdek waarom
Je hebt een X-Twilio-Signature-header, je code zegt dat die niet klopt, en de docs van Twilio antwoorden met een SDK om te installeren. Dat vertelt je niet welke van je URL, je params of je token fout is. Plak ze dus alle drie hieronder: we berekenen de handtekening opnieuw voor elke aannemelijke URL-vorm en tonen je welke matcht. De regel die matcht, is je diagnose.
Kort gezegd: een Twilio-handtekening faalt bijna altijd omdat de URL die je code opnieuw opbouwt niet byte voor byte gelijk is aan de URL die Twilio aanriep — meestal http:// in plaats van https:// achter een reverse proxy. Het algoritme is zelden het probleem.
Controleer een handtekening die Twilio afwees
De exacte string die Twilio ondertekent
Alle andere secties op deze pagina volgen hieruit. Twilio ondertekent niet je body, en ook niet je headers. Het ondertekent één string, die zo wordt opgebouwd:
- Begin met de volledige URL die Twilio aanriep: scheme, host, poort als die expliciet was, pad en de querystring.
- Sorteer de namen van de POST-parameters in ASCII-volgorde.
- Voeg voor elke parameter de naam toe, direct gevolgd door de waarde. Geen scheidingsteken, geen isgelijkteken, geen encoding, niets ertussen.
- Bereken de HMAC-SHA1 van de hele string met je Auth Token.
- Codeer de digest in base64. Dat is X-Twilio-Signature.
Hetzelfde met echte waarden
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=
Kijk wat er niet in staat. Geen &, geen =, geen percent-encoding: de waarde is +15017122661, niet %2B15017122661. Wie de params zelf opbouwt door de ruwe body te splitsen, vergeet meestal te decoderen en krijgt een handtekening die fout is om een reden die geen enkele foutmelding ooit noemt.
Verander één byte van die 101 tekens en de handtekening verandert volledig. Dat is het hele verhaal: je code en Twilio moeten exact dezelfde string opbouwen, en elke proxy, router en middleware daartussen kan die ongemerkt veranderen.
Vier URL's, vier handtekeningen, één waarheid
Zelfde Auth Token, zelfde vier params als hierboven. Alleen de URL verandert — en dat is genoeg:
| URL gebruikt om de string op te bouwen | Resulterende handtekening | Waar het vandaan komt |
|---|---|---|
| https://api.example.com/twilio/sms | REsmqJpWLoqe1Q/VJXTZjx4eviY= | Wat Twilio ondertekent |
| http://api.example.com/twilio/sms | NpKfrAiY/l6i2mXxfWlrjyRkytY= | Wat je app ziet achter SSL-terminatie |
| https://api.example.com:443/twilio/sms | vMbUG7KsMVUK80lzK6a+4IMFPqU= | Hoe een handmatig opgebouwde URL eruitziet |
| https://api.example.com/twilio/sms/ | 8LRHF+mPXykd0Jij9WhgsI7agMs= | Wat een router die paden normaliseert je geeft |
Niets in de fout vertelt je welke van de vier het is. Dat doet de tool: hij berekent ze allemaal, tot zestien URL-vormen, en vergelijkt elk ervan met de handtekening die Twilio echt stuurde. De groene regel is het antwoord.
De 8 echte oorzaken
Gerangschikt naar hoe vaak ze opduiken op het Twilio-communityforum en in StackOverflow-threads over deze fout. Die volgorde is onze eigen lezing, geen gemeten statistiek — gebruik haar als zoekvolgorde, niet als feit.
| # | Oorzaak | Het symptoom dat het verraadt | Oplossing |
|---|---|---|---|
| 1 | SSL-terminatie (X-Forwarded-Proto) | Slaagt met curl op localhost, faalt zodra het achter een load balancer staat. Je log toont http://, de Console toont https://. | Vertrouw de proxy: app.set('trust proxy', true), ProxyFix(app.wsgi_app, x_proto=1), TrustedProxies in Laravel. |
| 2 | Expliciete poort in de opnieuw opgebouwde URL | Je log toont https://host:443/… — Twilio schrijft nooit een impliciete poort. | Bouw op vanuit de Host- / X-Forwarded-Host-header. Plak hostname en poort niet aan elkaar. |
| 3 | Querystring weggelaten of opnieuw ge-encodeerd | Alleen de URL's met ?params falen. Je framework gaf het routepatroon terug, niet het ruwe target. | Gebruik het ruwe request-target (req.originalUrl, request.url) en laat de encoding met rust. |
| 4 | Params gewijzigd vóór de validatie | Faalt alleen bij sommige berichten — die met een leeg veld of witruimte aan het eind. | Valideer voordat een middleware de body aanraakt. Python: parse_qs(…, keep_blank_values=True). Laravel: sluit de route uit van TrimStrings. |
| 5 | JSON-body gevalideerd als formulierdata | Webhooks van Conversations en Studio falen, gewone sms-webhooks slagen. | Gebruik validateRequestWithBody() en de queryparameter bodySHA256. |
| 6 | Secundaire Auth Token niet gepromoveerd | Begon te falen direct na een tokenrotatie, op 100% van de requests. | Console → Account → Auth Token: een secundaire token ondertekent niets tot je hem promoveert tot Primary. |
| 7 | Herhaalde params (SDK-bug) | Alleen groepsberichten en Conversations falen, en niet reproduceerbaar. | twilio-node #722: params['MessagingBinding.Address'].sort() vóór validateRequest(). |
| 8 | Proxy-rewrite die het pad verandert | Faalt alleen in productie. Twilio roept /webhooks/sms aan, je app ontvangt /sms. | Valideer tegen het publieke pad, niet tegen het interne pad dat na de rewrite overblijft. |
De bug met herhaalde params — het ligt niet aan jou
Deze verdient een eigen sectie, want je kunt een uur in de docs van Twilio lezen zonder hem ooit te vinden. Groepsberichten en Conversations sturen dezelfde key meerdere keren:
MessagingBinding.Address=%2B15558675310&MessagingBinding.Address=%2B15017122661
De server van Twilio sorteert de keys en plakt ze daarna aan elkaar. Jouw params-map bevat een array. twilio-node doet data += key + params[key], en JavaScript zet die array om naar een string door de elementen met komma's samen te voegen — in volgorde van binnenkomst. Als je framework de twee adressen in een andere volgorde parste dan de server van Twilio gebruikte, verschillen de twee strings en faalt de validatie. Zelfde token, zelfde URL, zelfde params. Het faalt toch.
| Twee volgordes, twee handtekeningen — verder alles identiek | |
|---|---|
| Volgorde van binnenkomst: +15558675310, dan +15017122661 | LJaNX3wj9/RRc4+GG1kwgPndM1c= |
| Gesorteerd: +15017122661, dan +15558675310 | A7jNj66u8x1pDaqy3CNma9Q0XvA= |
// twilio-node issue #722
// Herhaalde params komen binnen als array; sorteer ze vóór het valideren.
if (Array.isArray(params['MessagingBinding.Address'])) {
params['MessagingBinding.Address'].sort();
}
const valid = twilio.validateRequest(authToken, signature, url, params);De tool test beide volgordes automatisch. Wordt de gesorteerde regel groen, dan heb je een bug in de SDK gevonden, niet in je code — en kun je stoppen met je handler steeds opnieuw door te lezen.
Werkt lokaal, breekt in productie
Het meest voorkomende verhaal, en het heeft één oorzaak: in dev geeft je tunnel de request aan je app door met hetzelfde scheme dat Twilio gebruikte. In productie beëindigen Traefik, nginx of Cloudflare de TLS en sturen ze gewone HTTP door op poort 3000. Je framework meldt wat het op zijn socket ziet — http:// — en ondertekent een string die Twilio nooit heeft opgebouwd.
// Achter Traefik / nginx / Cloudflare
app.set('trust proxy', true);
// req.protocol volgt nu 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 bouwt nu weer https:// op validator.validate(request.url, request.form, signature)
// app/Http/Middleware/TrustProxies.php protected $proxies = '*'; // alleen als de proxy van jou is protected $headers = Request::HEADER_X_FORWARDED_ALL;
Eén regel, drie ecosystemen. Zodra de proxy vertrouwd wordt, leest het framework X-Forwarded-Proto en X-Forwarded-Host, bouwt het https:// opnieuw op en matcht de handtekening. Vertrouw alleen proxy's die je echt beheert: met trust proxy op true kan alles upstream elk scheme claimen dat het wil.
JSON-bodies: bodySHA256
Twilio-webhooks zijn standaard application/x-www-form-urlencoded — nooit JSON. Maar Conversations en Studio kunnen JSON sturen, en dan veranderen de regels: de body zit helemaal niet in de params. Twilio hasht hem met SHA-256, voegt die hash als queryparameter bodySHA256 aan de URL toe en ondertekent de URL met een lege parameterlijst.
const valid = twilio.validateRequestWithBody( process.env.TWILIO_AUTH_TOKEN, req.headers['x-twilio-signature'], fullUrl, // moet nog steeds ?bodySHA256=… bevatten rawBody // de ruwe string, NIET eerst JSON.parse en dan weer stringify );
Hier zitten twee valkuilen. Strip je de querystring van de URL, dan strip je ook bodySHA256, en faalt de controle zonder dat de body ooit genoemd wordt. En als je framework de JSON al parste voordat jij erbij kwam, geeft JSON.stringify() je de oorspronkelijke bytes niet terug — sleutelvolgorde en witruimte zijn weg. Vang de ruwe body af. De JSON-modus in de tool hierboven toont beide hashes naast elkaar, zodat je ziet welke van de twee je te pakken had.
Twilio-feiten om in je achterhoofd te houden
Details die bepalen of de handtekening überhaupt een kans krijgt om gecontroleerd te worden:
- HMAC-SHA1, niet SHA256. Stripe, GitHub en Shopify gebruiken allemaal SHA-256, dus wie een werkende handler van een van hen kopieert en alleen de headernaam vervangt, krijgt een validator die nooit iets matcht.
- De payload is standaard
application/x-www-form-urlencoded, nooit JSON. Als je framework alleen JSON-bodies parst, is je params-map leeg en faalt elke handtekening. - Twilio wacht 15 seconden op een voice-webhook, 5 seconden bij Conversations. Valideer eerst, antwoord, en doe daarna het trage werk — een handtekeningcontrole die na een databasecall draait, kan een time-out krijgen voordat hij terugkomt.
- Standaard één nieuwe poging, en tot vijf met connection overrides. Een foute handtekening is dus niet één mislukt bericht: het is één bericht dat na een paar pogingen verloren gaat.
- De Debugger bewaart zijn logs 30 dagen. Daar vind je de URL die Twilio echt aanriep, en dat is precies de input die deze pagina nodig heeft.
- Bij WebSocket-verbindingen komt de header in kleine letters binnen:
x-twilio-signature. Vergelijk headers hoofdletterongevoelig.
En Twilio-fout 57012?
Fout 57012 (ongeldige handtekening) vertelt je dat de handtekening niet overeenkomt met de handtekening die Twilio berekende, en wijst naar de gebruikelijke verdachten: verkeerde token, verkeerde URL, params onderweg gewijzigd, body-hash ontbreekt. Allemaal waar. Maar het stopt precies waar het debuggen begint, want het kan je niet vertellen welke daarvan op jouw request van toepassing is. Let er ook op dat vanuit Twilio gezien de oorzaken 1, 2, 3, 7 en 8 hierboven allemaal gewoon “verkeerde URL” zijn — vijf verschillende bugs, vijf verschillende fixes, één foutcode. Die uit elkaar halen is het doel van deze pagina.
Wanneer gebruik je wat — eerlijk gezegd
We zijn hier niet het antwoord op alles, en doen alsof zou je middag verspillen.
Je schrijft de handler vanaf nul en wilt het referentie-algoritme en de SDK voor je taal. Het is de bron van waarheid — webhooks-security dekt zeven talen. Er is helemaal geen troubleshooting-sectie, en daarom ben je hier beland.
Je probleem is niet één kapotte request, maar de aflevering: je wilt retries die je zelf beheert, een dead letter queue, time-outs langer dan de 15 seconden van Twilio, omzetting van form naar JSON, of handtekeningverificatie aan de edge zodat je app nooit een niet-geverifieerde request ziet. Dat is een platform, en een echt platform. Betaal ervoor.
Eén handtekening, nu meteen, en je wilt weten welke regel van je code liegt. Dertig seconden, niets te installeren, geen account. Daarna ga je de proxyconfig fixen en vergeet je dat we bestaan. Prima uitkomst.
Als webhooks een vast onderdeel van je stack zijn in plaats van een brand die je aan het blussen bent, stuurt onze Relay-tunnel echt Twilio-verkeer door naar localhost, en speelt de Twilio-webhooktester ondertekende requests af op je handler, zodat je de fix kunt checken zonder een echte sms te verbruiken.
Veelgestelde vragen
Waarom faalt mijn Twilio-handtekening achter ngrok of een proxy?
Omdat de proxy de TLS beëindigt en gewone HTTP doorstuurt naar je app. Twilio ondertekende de https://-URL; je framework bouwt http:// op vanuit zijn eigen socket. De strings verschillen, dus de HMAC verschilt. Vertrouw de proxy zodat X-Forwarded-Proto wordt gebruikt om de URL op te bouwen, en hij matcht weer.
Moet de poort in de URL staan?
Alleen als Twilio hem aanriep. Twilio ondertekent de URL zoals geconfigureerd, dus een standaardpoort 443 komt nooit in de string voor. Voeg je zelf :443 toe bij het opbouwen, dan krijg je een andere handtekening. Bouw de host op vanuit de Host-header in plaats van hostname en poort aan elkaar te plakken.
Hoe valideer ik een JSON-body van Twilio?
Met validateRequestWithBody() in plaats van validateRequest(). Twilio hasht de body met SHA-256, zet de hash in een queryparameter bodySHA256 en ondertekent de URL zonder params. Je hebt de ruwe bytes van de body nodig en de volledige URL inclusief die queryparameter.
Waarom werkt het lokaal maar niet in productie?
In dev geeft je tunnel je app meestal hetzelfde scheme dat Twilio gebruikte. In productie krijgt je app van Traefik, nginx of Cloudflare gewone HTTP op een interne poort. Zelfde code, andere opnieuw opgebouwde URL, dus de handtekening matcht niet meer. Het is de nummer-één-oorzaak op deze pagina.
Wordt mijn Auth Token naar jullie servers gestuurd?
Nee. De HMAC draait in je browser met WebCrypto, en de tool verstuurt geen enkele request zolang je hem gebruikt. Open je netwerktabblad en kijk mee. Je Auth Token is een productiesecret — dit zou je moeten vragen aan elke site die erom vraagt, ook aan de onze.
Is de Twilio-handtekening SHA-256, zoals die van Stripe?
Nee, Twilio gebruikt HMAC-SHA1 en codeert die in base64. Stripe, GitHub en Shopify gebruiken SHA-256. Een werkende Stripe-handler aanpassen door de header te hernoemen levert een validator op die alles weigert, en dat is een veelvoorkomende manier om bij deze fout uit te komen.