Twilio-Signaturprüfung fehlgeschlagen — finde heraus, warum
Du hast einen X-Twilio-Signature-Header, dein Code sagt, er passt nicht, und Twilios Doku antwortet mit einem SDK zum Installieren. Das sagt dir nicht, was falsch ist: deine URL, deine Parameter oder dein Token. Also füg alle drei unten ein: Wir berechnen die Signatur für jede plausible URL-Form neu und zeigen dir die, die passt. Die passende Zeile ist deine Diagnose.
Kurz gesagt: Eine Twilio-Signatur scheitert fast immer, weil die URL, die dein Code rekonstruiert, nicht byte-identisch mit der URL ist, die Twilio aufgerufen hat — meist http:// statt https:// hinter einem Reverse Proxy. Der Algorithmus ist selten das Problem.
Eine von Twilio abgelehnte Signatur prüfen
Der exakte String, den Twilio signiert
Alle anderen Abschnitte dieser Seite ergeben sich aus diesem. Twilio signiert weder deinen Body noch deine Headers. Es signiert einen einzigen String, der so aufgebaut wird:
- Beginne mit der vollständigen URL, die Twilio aufgerufen hat: Schema, Host, Port (falls explizit), Pfad und Query-String.
- Sortiere die Namen der POST-Parameter in ASCII-Reihenfolge.
- Hänge für jeden den Namen an, direkt gefolgt von seinem Wert. Kein Trennzeichen, kein Gleichheitszeichen, keine Kodierung, nichts dazwischen.
- Bilde über den ganzen String einen HMAC-SHA1 mit deinem Auth Token.
- Kodiere den Digest in Base64. Das ist X-Twilio-Signature.
Dasselbe mit echten Werten
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=
Achte darauf, was nicht drinsteht. Kein &, kein =, kein Percent-Encoding: Der Wert ist +15017122661, nicht %2B15017122661. Wer die Parameter selbst durch Aufsplitten des rohen Bodys rekonstruiert, vergisst meist das Dekodieren — und bekommt eine Signatur, die aus einem Grund falsch ist, den keine Fehlermeldung je nennen wird.
Ändere ein Byte dieser 101 Zeichen, und die Signatur ändert sich komplett. Das ist die ganze Geschichte: Dein Code und Twilio müssen exakt denselben String bauen, und jeder Proxy, Router und jede Middleware dazwischen kann ihn ändern, ohne dir Bescheid zu sagen.
Vier URLs, vier Signaturen, eine Wahrheit
Derselbe Auth Token, dieselben vier Parameter wie oben. Nur die URL ändert sich — und das reicht schon:
| Für den String verwendete URL | Resultierende Signatur | Woher sie kommt |
|---|---|---|
| https://api.example.com/twilio/sms | REsmqJpWLoqe1Q/VJXTZjx4eviY= | Was Twilio signiert |
| http://api.example.com/twilio/sms | NpKfrAiY/l6i2mXxfWlrjyRkytY= | Was deine App hinter SSL-Terminierung sieht |
| https://api.example.com:443/twilio/sms | vMbUG7KsMVUK80lzK6a+4IMFPqU= | So sieht eine von Hand gebaute URL aus |
| https://api.example.com/twilio/sms/ | 8LRHF+mPXykd0Jij9WhgsI7agMs= | Was ein Router liefert, der Pfade normalisiert |
Nichts am Fehler verrät dir, bei welcher der vier du bist. Genau das macht das Tool: Es berechnet sie alle, bis zu sechzehn URL-Formen, und vergleicht jede mit der Signatur, die Twilio tatsächlich gesendet hat. Die grüne Zeile ist die Antwort.
Die 8 echten Ursachen
Sortiert danach, wie oft sie im Twilio-Community-Forum und in StackOverflow-Threads zu diesem Fehler auftauchen. Diese Reihenfolge ist unsere Lesart, keine gemessene Statistik — nimm sie als Suchreihenfolge, nicht als Fakt.
| # | Ursache | Das Symptom, das sie verrät | Lösung |
|---|---|---|---|
| 1 | SSL-Terminierung (X-Forwarded-Proto) | Klappt mit curl auf localhost, scheitert, sobald ein Load Balancer davorsteht. Dein Log zeigt http://, die Console zeigt https://. | Vertrau dem Proxy: app.set('trust proxy', true), ProxyFix(app.wsgi_app, x_proto=1), TrustedProxies bei Laravel. |
| 2 | Expliziter Port in der rekonstruierten URL | Dein Log zeigt https://host:443/… — Twilio schreibt nie einen impliziten Port. | Aus dem Header Host / X-Forwarded-Host aufbauen. Hostname und Port nicht zusammensetzen. |
| 3 | Query-String entfernt oder neu kodiert | Nur URLs mit ?params scheitern. Dein Framework hat das Routenmuster geliefert, nicht das rohe Ziel. | Das rohe Request-Ziel verwenden (req.originalUrl, request.url) und die Kodierung nicht anfassen. |
| 4 | Parameter vor der Prüfung verändert | Scheitert nur bei manchen Nachrichten — denen mit einem leeren Feld oder abschließenden Leerzeichen. | Prüfen, bevor irgendeine Middleware den Body anfasst. Python: parse_qs(…, keep_blank_values=True). Laravel: die Route von TrimStrings ausnehmen. |
| 5 | JSON-Body als Formulardaten geprüft | Conversations- und Studio-Webhooks scheitern, normale SMS-Webhooks klappen. | validateRequestWithBody() und den Query-Parameter bodySHA256 verwenden. |
| 6 | Sekundärer Auth Token nicht hochgestuft | Scheitert direkt nach einer Token-Rotation, bei 100 % der Requests. | Console → Account → Auth Token: Ein sekundärer Token signiert nichts, bis du ihn zum Primary hochstufst. |
| 7 | Wiederholte Parameter (SDK-Bug) | Nur Gruppennachrichten und Conversations scheitern, und nicht reproduzierbar. | twilio-node #722: params['MessagingBinding.Address'].sort() vor validateRequest(). |
| 8 | Proxy-Rewrite ändert den Pfad | Scheitert nur in Produktion. Twilio ruft /webhooks/sms auf, deine App empfängt /sms. | Gegen den öffentlichen Pfad prüfen, nicht gegen den internen, der nach dem Rewrite übrig bleibt. |
Der Bug mit wiederholten Parametern — nicht deine Schuld
Dieser verdient einen eigenen Abschnitt, denn du kannst eine Stunde in Twilios Doku lesen und ihn nie finden. Gruppennachrichten und Conversations senden denselben Key mehrfach:
MessagingBinding.Address=%2B15558675310&MessagingBinding.Address=%2B15017122661
Twilios Server sortiert die Keys und verkettet dann. Deine Parameter-Map enthält ein Array. twilio-node macht data += key + params[key], und JavaScript wandelt dieses Array in einen String um, indem es die Elemente mit Kommas verbindet — in Ankunftsreihenfolge. Hat dein Framework die beiden Adressen in einer anderen Reihenfolge geparst als Twilios Server, unterscheiden sich die beiden Strings, und die Prüfung scheitert. Derselbe Token, dieselbe URL, dieselben Parameter. Scheitert trotzdem.
| Zwei Reihenfolgen, zwei Signaturen — alles andere identisch | |
|---|---|
| Ankunftsreihenfolge: +15558675310, dann +15017122661 | LJaNX3wj9/RRc4+GG1kwgPndM1c= |
| Sortiert: +15017122661, dann +15558675310 | A7jNj66u8x1pDaqy3CNma9Q0XvA= |
// twilio-node issue #722
// Wiederholte Parameter kommen als Array an; vor der Prüfung sortieren.
if (Array.isArray(params['MessagingBinding.Address'])) {
params['MessagingBinding.Address'].sort();
}
const valid = twilio.validateRequest(authToken, signature, url, params);Das Tool testet beide Reihenfolgen automatisch. Wird die sortierte Zeile grün, hast du einen Bug im SDK gefunden, nicht in deinem Code — und kannst aufhören, deinen Handler zum zehnten Mal zu lesen.
Klappt lokal, bricht in Produktion
Die mit Abstand häufigste Geschichte, und sie hat eine einzige Ursache: In der Entwicklung übergibt dein Tunnel den Request mit demselben Schema an deine App, das Twilio verwendet hat. In Produktion terminieren Traefik, nginx oder Cloudflare TLS und leiten reines HTTP auf Port 3000 weiter. Dein Framework meldet, was es auf seinem Socket sieht — http:// — und signiert einen String, den Twilio nie gebaut hat.
// Hinter Traefik / nginx / Cloudflare
app.set('trust proxy', true);
// req.protocol folgt jetzt 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 rekonstruiert jetzt https:// validator.validate(request.url, request.form, signature)
// app/Http/Middleware/TrustProxies.php protected $proxies = '*'; // nur, wenn der Proxy dir gehört protected $headers = Request::HEADER_X_FORWARDED_ALL;
Eine Zeile, drei Ökosysteme. Sobald dem Proxy vertraut wird, liest das Framework X-Forwarded-Proto und X-Forwarded-Host, rekonstruiert https://, und die Signatur passt. Vertrau nur Proxys, die du wirklich kontrollierst: Mit trust proxy auf true kann alles davor jedes beliebige Schema behaupten.
JSON-Bodys: bodySHA256
Twilio-Webhooks sind standardmäßig application/x-www-form-urlencoded — nie JSON. Conversations und Studio können aber JSON senden, und dann ändern sich die Regeln: Der Body steckt gar nicht in den Parametern. Twilio hasht ihn mit SHA-256, hängt diesen Hash als Query-Parameter bodySHA256 an die URL und signiert die URL mit einer leeren Parameterliste.
const valid = twilio.validateRequestWithBody( process.env.TWILIO_AUTH_TOKEN, req.headers['x-twilio-signature'], fullUrl, // muss weiterhin ?bodySHA256=… enthalten rawBody // der rohe String, NICHT per JSON.parse geparst und neu serialisiert );
Hier lauern zwei Fallen. Entfernst du den Query-String aus der URL, entfernst du auch bodySHA256, und die Prüfung scheitert, ohne den Body je zu erwähnen. Und hat dein Framework das JSON schon geparst, bevor du drankommst, gibt dir JSON.stringify() die Original-Bytes nicht zurück — Key-Reihenfolge und Leerzeichen sind weg. Erfasse den rohen Body. Der JSON-Modus im Tool oben zeigt beide Hashes nebeneinander, damit du siehst, welche der beiden dich erwischt hat.
Twilio-Fakten, die du im Kopf haben solltest
Details, die entscheiden, ob die Signatur überhaupt eine Chance bekommt, geprüft zu werden:
- HMAC-SHA1, nicht SHA256. Stripe, GitHub und Shopify nutzen alle SHA-256 — wer einen funktionierenden Handler von einem davon kopiert und nur den Header-Namen tauscht, bekommt einen Validator, der nie etwas matcht.
- Die Payload ist standardmäßig
application/x-www-form-urlencoded, nie JSON. Parst dein Framework nur JSON-Bodys, ist deine Parameter-Map leer, und jede Signatur scheitert. - Twilio wartet bei einem Voice-Webhook 15 Sekunden, bei Conversations 5 Sekunden. Erst prüfen, antworten, dann die langsame Arbeit erledigen — eine Signaturprüfung nach einem Datenbankaufruf kann in den Timeout laufen, bevor sie zurückkommt.
- Standardmäßig ein Retry, mit Connection Overrides bis zu fünf. Eine falsche Signatur ist also nicht eine fehlgeschlagene Nachricht, sondern eine Nachricht, die nach ein paar Versuchen verloren ist.
- Der Debugger bewahrt seine Logs 30 Tage auf. Dort findest du die URL, die Twilio tatsächlich aufgerufen hat — genau der Input, den diese Seite braucht.
- Bei WebSocket-Verbindungen kommt der Header kleingeschrieben an:
x-twilio-signature. Vergleiche Header-Namen ohne Beachtung der Groß-/Kleinschreibung.
Und der Twilio-Fehler 57012?
Fehler 57012 (ungültige Signatur) sagt dir, dass die Signatur nicht zu der passt, die Twilio berechnet hat, und nennt die üblichen Verdächtigen: falscher Token, falsche URL, unterwegs geänderte Parameter, fehlender Body-Hash. Alles richtig. Aber er hört genau dort auf, wo das Debugging anfängt, denn er kann dir nicht sagen, was davon auf deinen Request zutrifft. Beachte außerdem: Aus Twilios Sicht sind die Ursachen 1, 2, 3, 7 und 8 oben alle nur „falsche URL“ — fünf verschiedene Bugs, fünf verschiedene Lösungen, ein Fehlercode. Sie auseinanderzuhalten ist der Zweck dieser Seite.
Wann was — ehrlich
Wir sind hier nicht die Antwort auf alles, und so zu tun, würde dir den Nachmittag kosten.
Du schreibst den Handler von Grund auf und willst den Referenzalgorithmus und das SDK für deine Sprache. Sie ist die maßgebliche Quelle — webhooks-security deckt sieben Sprachen ab. Einen Troubleshooting-Abschnitt hat sie überhaupt nicht, deshalb bist du hier gelandet.
Dein Problem ist nicht ein kaputter Request, sondern die Zustellung: Du willst Retries, die du steuerst, eine Dead Letter Queue, Timeouts über Twilios 15 Sekunden hinaus, Form-zu-JSON-Transformation oder Signaturprüfung am Edge, damit deine App nie einen ungeprüften Request sieht. Das ist eine Plattform, und zwar eine echte. Zahl dafür.
Eine Signatur, jetzt sofort, und du willst wissen, welche Zeile deines Codes lügt. Dreißig Sekunden, nichts zu installieren, kein Konto. Dann korrigierst du die Proxy-Konfiguration und vergisst, dass es uns gibt. Das ist ein gutes Ergebnis.
Sind Webhooks ein fester Teil deines Stacks und nicht nur ein Brand, den du gerade löschst, leitet unser Relay-Tunnel echten Twilio-Traffic an localhost weiter, und der Twilio-Webhook-Tester spielt signierte Requests gegen deinen Handler ab, damit du den Fix prüfen kannst, ohne eine echte SMS zu verbrauchen.
Häufige Fragen
Warum scheitert meine Twilio-Signatur hinter ngrok oder einem Proxy?
Weil der Proxy TLS terminiert und reines HTTP an deine App weiterleitet. Twilio hat die https://-URL signiert; dein Framework rekonstruiert http:// aus seinem eigenen Socket. Die Strings unterscheiden sich, der HMAC auch. Vertrau dem Proxy, damit X-Forwarded-Proto zum Rekonstruieren der URL verwendet wird, und sie passt wieder.
Gehört der Port in die URL?
Nur, wenn Twilio ihn aufgerufen hat. Twilio signiert die URL wie konfiguriert, ein Standard-443 taucht im String also nie auf. Hängst du beim Rekonstruieren selbst :443 an, bekommst du eine andere Signatur. Rekonstruiere den Host aus dem Host-Header, statt Hostname und Port zusammenzusetzen.
Wie prüfe ich einen JSON-Body von Twilio?
Mit validateRequestWithBody() statt validateRequest(). Twilio hasht den Body mit SHA-256, legt den Hash in einen Query-Parameter bodySHA256 und signiert die URL ohne Parameter. Du brauchst die rohen Body-Bytes und die vollständige URL inklusive dieses Query-Parameters.
Warum klappt es lokal, aber nicht in Produktion?
In der Entwicklung gibt dein Tunnel deiner App meist dasselbe Schema, das Twilio verwendet hat. In Produktion reichen Traefik, nginx oder Cloudflare reines HTTP auf einem internen Port weiter. Gleicher Code, andere rekonstruierte URL, also passt die Signatur nicht mehr. Das ist die Ursache Nummer eins auf dieser Seite.
Wird mein Auth Token an eure Server gesendet?
Nein. Der HMAC läuft mit WebCrypto in deinem Browser, und das Tool sendet keinen Request, solange du es nutzt. Öffne deinen Netzwerk-Tab und sieh zu. Dein Auth Token ist ein Produktions-Secret — das solltest du jede Website fragen, die danach verlangt, unsere eingeschlossen.
Ist die Twilio-Signatur SHA-256, wie bei Stripe?
Nein, Twilio nutzt HMAC-SHA1 und kodiert das Ergebnis in Base64. Stripe, GitHub und Shopify nutzen SHA-256. Wer einen funktionierenden Stripe-Handler durch Umbenennen des Headers anpasst, bekommt einen Validator, der alles ablehnt — ein häufiger Weg zu genau diesem Fehler.