Walidacja podpisu Twilio nie powiodła się — sprawdź dlaczego
Masz nagłówek X-Twilio-Signature, Twój kod twierdzi, że się nie zgadza, a dokumentacja Twilio odpowiada SDK do zainstalowania. To nie mówi Ci, co jest źle: URL, parametry czy token. Wklej więc wszystkie trzy poniżej: przeliczamy podpis dla każdej prawdopodobnej postaci URL i pokazujemy tę, która pasuje. Pasujący wiersz to Twoja diagnoza.
W skrócie: podpis Twilio prawie zawsze zawodzi, bo URL odtworzony przez Twój kod nie jest bajt w bajt identyczny z URL-em, który wywołało Twilio — najczęściej http:// zamiast https:// za reverse proxy. Algorytm rzadko jest problemem.
Sprawdź podpis odrzucony przez Twilio
Dokładny ciąg, który podpisuje Twilio
Wszystko inne na tej stronie wynika z tej sekcji. Twilio nie podpisuje body ani nagłówków. Podpisuje jeden ciąg, zbudowany tak:
- Zacznij od pełnego URL-a, który wywołało Twilio: schemat, host, port (jeśli był podany jawnie), ścieżka i query string.
- Posortuj nazwy parametrów POST w kolejności ASCII.
- Dla każdego dopisz nazwę, a zaraz po niej wartość. Bez separatora, bez znaku równości, bez kodowania, nic pomiędzy.
- Policz HMAC-SHA1 z całego ciągu, używając swojego Auth Tokena.
- Zakoduj wynik w Base64. To jest X-Twilio-Signature.
To samo na prawdziwych wartościach
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=
Zobacz, czego tu nie ma. Żadnego &, żadnego =, żadnego percent-encodingu: wartość to +15017122661, a nie %2B15017122661. Kto sam odtwarza parametry, dzieląc surowe body, zwykle zapomina je zdekodować — i dostaje błędny podpis z powodu, o którym żaden komunikat błędu nigdy nie wspomni.
Zmień jeden bajt z tych 101 znaków, a podpis zmieni się całkowicie. To cała historia: Twój kod i Twilio muszą zbudować dokładnie ten sam ciąg, a każde proxy, router i middleware po drodze może go zmienić bez słowa.
Cztery URL-e, cztery podpisy, jedna prawda
Ten sam Auth Token, te same cztery parametry co wyżej. Zmienia się tylko URL — i to wystarczy:
| URL użyty do zbudowania ciągu | Otrzymany podpis | Skąd się bierze |
|---|---|---|
| https://api.example.com/twilio/sms | REsmqJpWLoqe1Q/VJXTZjx4eviY= | To, co podpisuje Twilio |
| http://api.example.com/twilio/sms | NpKfrAiY/l6i2mXxfWlrjyRkytY= | To, co widzi Twoja aplikacja za terminacją SSL |
| https://api.example.com:443/twilio/sms | vMbUG7KsMVUK80lzK6a+4IMFPqU= | Tak wygląda URL sklejany ręcznie |
| https://api.example.com/twilio/sms/ | 8LRHF+mPXykd0Jij9WhgsI7agMs= | To daje router normalizujący ścieżki |
Nic w samym błędzie nie mówi, w którym z czterech przypadków jesteś. Od tego jest narzędzie: liczy wszystkie, do szesnastu postaci URL, i porównuje każdą z podpisem, który Twilio faktycznie wysłało. Zielony wiersz to odpowiedź.
8 prawdziwych przyczyn
Uporządkowane według tego, jak często pojawiają się na forum społeczności Twilio i w wątkach StackOverflow o tym błędzie. Ta kolejność to nasza interpretacja, a nie zmierzona statystyka — traktuj ją jako kolejność szukania, nie jako fakt.
| # | Przyczyna | Objaw, który ją zdradza | Rozwiązanie |
|---|---|---|---|
| 1 | Terminacja SSL (X-Forwarded-Proto) | Działa z curl na localhost, przestaje działać, gdy tylko trafi za load balancer. Twój log pokazuje http://, a konsola https://. | Zaufaj proxy: app.set('trust proxy', true), ProxyFix(app.wsgi_app, x_proto=1), TrustedProxies w Laravelu. |
| 2 | Jawny port w odtworzonym URL-u | Twój log pokazuje https://host:443/… — Twilio nigdy nie wpisuje portu domyślnego. | Buduj z nagłówka Host / X-Forwarded-Host. Nie sklejaj hostname i portu. |
| 3 | Query string pominięty lub przekodowany | Zawodzą tylko URL-e z ?parametrami. Framework zwrócił wzorzec trasy, a nie surowy target. | Użyj surowego targetu żądania (req.originalUrl, request.url) i nie ruszaj kodowania. |
| 4 | Parametry zmienione przed walidacją | Zawodzi tylko przy niektórych wiadomościach — tych z pustym polem lub spacją na końcu. | Waliduj, zanim jakikolwiek middleware dotknie body. Python: parse_qs(…, keep_blank_values=True). Laravel: wyłącz trasę z TrimStrings. |
| 5 | Body JSON walidowane jak dane formularza | Webhooki Conversations i Studio zawodzą, zwykłe webhooki SMS przechodzą. | Użyj validateRequestWithBody() i parametru query bodySHA256. |
| 6 | Niewypromowany drugi Auth Token | Zaczęło zawodzić tuż po rotacji tokena, na 100% żądań. | Console → Account → Auth Token: token pomocniczy niczego nie podpisuje, dopóki nie wypromujesz go na Primary. |
| 7 | Powtórzone parametry (błąd SDK) | Zawodzą tylko wiadomości grupowe i Conversations, i to nie w sposób powtarzalny. | twilio-node #722: params['MessagingBinding.Address'].sort() przed validateRequest(). |
| 8 | Rewrite w proxy zmieniający ścieżkę | Zawodzi tylko na produkcji. Twilio wywołuje /webhooks/sms, Twoja aplikacja dostaje /sms. | Waliduj względem publicznej ścieżki, a nie wewnętrznej, która zostaje po rewrite. |
Błąd powtórzonych parametrów — to nie Twoja wina
Ta przyczyna zasługuje na osobną sekcję, bo możesz czytać dokumentację Twilio godzinę i nigdy jej nie znaleźć. Wiadomości grupowe i Conversations wysyłają ten sam klucz kilka razy:
MessagingBinding.Address=%2B15558675310&MessagingBinding.Address=%2B15017122661
Serwer Twilio sortuje klucze, a potem skleja. Twoja mapa parametrów zawiera tablicę. twilio-node robi data += key + params[key], a JavaScript zamienia tę tablicę na tekst, łącząc elementy przecinkami — w kolejności nadejścia. Jeśli Twój framework sparsował oba adresy w innej kolejności niż serwer Twilio, ciągi się różnią i walidacja zawodzi. Ten sam token, ten sam URL, te same parametry. A i tak zawodzi.
| Dwie kolejności, dwa podpisy — cała reszta identyczna | |
|---|---|
| Kolejność nadejścia: +15558675310, potem +15017122661 | LJaNX3wj9/RRc4+GG1kwgPndM1c= |
| Posortowane: +15017122661, potem +15558675310 | A7jNj66u8x1pDaqy3CNma9Q0XvA= |
// twilio-node issue #722
// Powtórzone parametry przychodzą jako tablica; posortuj je przed walidacją.
if (Array.isArray(params['MessagingBinding.Address'])) {
params['MessagingBinding.Address'].sort();
}
const valid = twilio.validateRequest(authToken, signature, url, params);Narzędzie automatycznie testuje obie kolejności. Jeśli posortowany wiersz świeci na zielono, błąd tkwi w SDK, a nie w Twoim kodzie — i możesz przestać po raz kolejny czytać swój handler.
Lokalnie działa, na produkcji się sypie
Najczęstsza historia i ma jedną przyczynę: w dev Twój tunel przekazuje żądanie do aplikacji tym samym schematem, którego użyło Twilio. Na produkcji Traefik, nginx lub Cloudflare terminują TLS i przekazują zwykłe HTTP na port 3000. Twój framework raportuje to, co widzi na swoim sockecie — http:// — i podpisuje ciąg, którego Twilio nigdy nie zbudowało.
// Za Traefikiem / nginx / Cloudflare
app.set('trust proxy', true);
// req.protocol teraz podąża za 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 teraz odtwarza https:// validator.validate(request.url, request.form, signature)
// app/Http/Middleware/TrustProxies.php protected $proxies = '*'; // tylko jeśli proxy jest Twoje protected $headers = Request::HEADER_X_FORWARDED_ALL;
Jedna linijka, trzy ekosystemy. Gdy proxy jest zaufane, framework czyta X-Forwarded-Proto i X-Forwarded-Host, odtwarza https:// i podpis się zgadza. Ufaj tylko proxy, które faktycznie kontrolujesz: przy trust proxy ustawionym na true wszystko, co stoi przed aplikacją, może podać dowolny schemat.
Body JSON: bodySHA256
Webhooki Twilio są domyślnie application/x-www-form-urlencoded — nigdy JSON. Ale Conversations i Studio potrafią wysyłać JSON, a wtedy zasady się zmieniają: body w ogóle nie trafia do parametrów. Twilio hashuje je SHA-256, dopisuje ten hash do URL-a jako parametr query bodySHA256 i podpisuje URL z pustą listą parametrów.
const valid = twilio.validateRequestWithBody( process.env.TWILIO_AUTH_TOKEN, req.headers['x-twilio-signature'], fullUrl, // musi nadal zawierać ?bodySHA256=… rawBody // surowy ciąg, NIE po JSON.parse i ponownym stringify );
Są tu dwie pułapki. Usuń query string z URL-a, a usuniesz też bodySHA256 — weryfikacja zawiedzie, nie wspominając nawet o body. A jeśli framework sparsował JSON, zanim trafił on do Twojego kodu, JSON.stringify() nie odda Ci oryginalnych bajtów — kolejność kluczy i białe znaki przepadły. Przechwyć surowe body. Tryb JSON w narzędziu powyżej pokazuje oba hashe obok siebie, więc od razu widać, która z dwóch pułapek zadziałała.
Fakty o Twilio, które warto mieć w głowie
Szczegóły, od których zależy, czy podpis w ogóle ma szansę zostać sprawdzony:
- HMAC-SHA1, a nie SHA256. Stripe, GitHub i Shopify używają SHA-256, więc skopiowanie działającego handlera od któregoś z nich i podmiana nazwy nagłówka daje walidator, który nigdy niczego nie dopasuje.
- Payload jest domyślnie
application/x-www-form-urlencoded, nigdy JSON. Jeśli Twój framework parsuje tylko body JSON, mapa parametrów jest pusta i każdy podpis zawodzi. - Twilio czeka 15 sekund na webhook głosowy i 5 sekund w Conversations. Najpierw waliduj, potem odpowiedz, a dopiero potem wykonuj powolną pracę — sprawdzenie podpisu uruchomione po zapytaniu do bazy może przekroczyć limit czasu, zanim zwróci wynik.
- Domyślnie jedna ponowna próba, a z nadpisaniem ustawień połączenia do pięciu. Zły podpis to więc nie jedna nieudana wiadomość, tylko wiadomość utracona po kilku próbach.
- Debugger przechowuje logi przez 30 dni. Tam znajdziesz URL, który Twilio faktycznie wywołało — czyli dane wejściowe, których potrzebuje ta strona.
- Przy połączeniach WebSocket nagłówek przychodzi małymi literami:
x-twilio-signature. Porównuj nagłówki bez rozróżniania wielkości liter.
A błąd Twilio 57012?
Błąd 57012 (nieprawidłowy podpis) mówi, że podpis nie zgadza się z tym, który policzyło Twilio, i wskazuje zwykłych podejrzanych: zły token, zły URL, parametry zmienione po drodze, brak hasha body. Wszystko prawda. Ale kończy się dokładnie tam, gdzie zaczyna się debugowanie, bo nie potrafi powiedzieć, który z nich dotyczy Twojego żądania. Zauważ też, że z perspektywy Twilio przyczyny 1, 2, 3, 7 i 8 powyżej to po prostu „zły URL” — pięć różnych błędów, pięć różnych poprawek, jeden kod błędu. Rozdzielenie ich to cały sens tej strony.
Czego kiedy używać — uczciwie
Nie jesteśmy tu odpowiedzią na wszystko, a udawanie, że jest inaczej, zmarnowałoby Ci popołudnie.
Piszesz handler od zera i potrzebujesz referencyjnego algorytmu oraz SDK dla swojego języka. To źródło prawdy — webhooks-security obejmuje siedem języków. Nie ma za to żadnej sekcji o rozwiązywaniu problemów, i dlatego jesteś tutaj.
Twój problem to nie jedno zepsute żądanie, tylko dostarczanie: chcesz ponownych prób pod swoją kontrolą, dead letter queue, timeoutów dłuższych niż 15 sekund Twilio, konwersji formularza na JSON albo weryfikacji podpisu na brzegu, żeby aplikacja nigdy nie widziała niezweryfikowanego żądania. To platforma, i to prawdziwa. Zapłać za nią.
Jeden podpis, teraz, i chcesz wiedzieć, która linijka Twojego kodu kłamie. Trzydzieści sekund, nic do instalowania, bez konta. Potem idziesz poprawić konfigurację proxy i zapominasz, że istniejemy. To dobre zakończenie.
Jeśli webhooki są stałym elementem Twojego stacku, a nie pożarem do ugaszenia, nasz tunel Relay przekazuje prawdziwy ruch Twilio do localhost, a tester webhooków Twilio odtwarza podpisane żądania na Twoim handlerze, więc sprawdzisz poprawkę bez wysyłania prawdziwego SMS-a.
Najczęstsze pytania
Dlaczego mój podpis Twilio nie przechodzi za ngrokiem lub proxy?
Bo proxy terminuje TLS i przekazuje do aplikacji zwykłe HTTP. Twilio podpisało URL https://, a Twój framework odtwarza http:// ze swojego socketu. Ciągi się różnią, więc HMAC też. Zaufaj proxy, żeby URL był odtwarzany z X-Forwarded-Proto, i podpis znów się zgodzi.
Czy port powinien być w URL-u?
Tylko jeśli Twilio go wywołało. Twilio podpisuje URL w takiej postaci, w jakiej jest skonfigurowany, więc standardowy 443 nigdy nie pojawia się w ciągu. Dopisz :443 sam przy odtwarzaniu, a dostaniesz inny podpis. Odtwarzaj host z nagłówka Host zamiast sklejać hostname i port.
Jak zwalidować body JSON od Twilio?
Za pomocą validateRequestWithBody() zamiast validateRequest(). Twilio hashuje body SHA-256, umieszcza hash w parametrze query bodySHA256 i podpisuje URL bez parametrów. Potrzebujesz surowych bajtów body i pełnego URL-a razem z tym parametrem query.
Dlaczego działa lokalnie, a na produkcji nie?
W dev Twój tunel zwykle przekazuje aplikacji ten sam schemat, którego użyło Twilio. Na produkcji Traefik, nginx lub Cloudflare przekazują jej zwykłe HTTP na wewnętrznym porcie. Ten sam kod, inny odtworzony URL, więc podpis przestaje się zgadzać. To przyczyna numer jeden na tej stronie.
Czy mój Auth Token jest wysyłany na Wasze serwery?
Nie. HMAC liczy się w Twojej przeglądarce za pomocą WebCrypto, a narzędzie nie wysyła żadnego żądania, gdy z niego korzystasz. Otwórz zakładkę sieci i sprawdź. Twój Auth Token to produkcyjny sekret — zadawaj to pytanie każdej stronie, która o niego prosi, także naszej.
Czy podpis Twilio to SHA-256, jak w Stripe?
Nie, Twilio używa HMAC-SHA1 i koduje wynik w base64. Stripe, GitHub i Shopify używają SHA-256. Przerobienie działającego handlera Stripe przez zmianę nazwy nagłówka daje walidator, który odrzuca wszystko — to częsta droga do tego błędu.