Większość błędów weryfikacji podpisu webhooków to nie bugi kryptograficzne. To bugi parsowania. Ktoś sparsował ciało przed weryfikacją. Ktoś użył hex, gdy dostawca chciał base64. Ktoś porównał przez ===, gdy potrzebne było porównanie timing-safe. Ten przewodnik to skrócona wersja każdego problemu z podpisem, jaki kiedykolwiek debugowaliśmy.
Model, którego używają dostawcy
Każdy duży dostawca webhooków — Stripe, GitHub, Shopify, Twilio, Slack, Discord — robi w istocie to samo w innym przebraniu:
- Ty i dostawca dzielicie sekret.
- Dostawca oblicza podpis na ciele żądania (czasem plus timestamp, czasem plus URL).
- Podpis wędruje w nagłówku.
- Twój handler przelicza podpis tym samym sekretem i odrzuca żądanie, jeśli się nie zgadzają.
Co się różni między dostawcami: algorytm (najczęściej HMAC SHA-256; Discord używa Ed25519), kodowanie (hex vs. base64), co jest podpisywane (ciało, ciało+timestamp, URL+ciało+parametry) i który nagłówek niesie podpis.
Cztery bugi, które tłumaczą prawie wszystko
1. Parsowanie ciała najpierw
Większość frameworków ma middleware JSON, które parsuje przychodzące ciała na obiekty. Gdy się uruchomi, surowe bajty znikają, a każdy obliczony podpis się nie zgodzi — nawet jeśli ponownie zserializujesz sparsowany obiekt, białe znaki i kolejność kluczy mogą się różnić.
Rozwiązanie: czytaj surowe ciało przed jakimkolwiek middleware na trasie webhooka. W Express zamontuj express.raw({ type: 'application/json' }) tylko na ścieżce webhooka. W Next.js App Router czytaj await request.text() bezpośrednio. W Django użyj @csrf_exempt + request.body zanim uruchomi się jakikolwiek parser DRF.
2. Złe kodowanie
Stripe i GitHub używają hex. Shopify używa base64. Ten sam sekret daje w każdym inaczej wyglądający wynik. Omówiliśmy to w przewodniku Shopify — gryzie każdego.
3. Brak timing-safe
Równość ciągów (===) kończy się wcześnie na pierwszym niezgodnym znaku. Różnica czasu między „pierwszy znak się zgadza" a „wszystkie 64 znaki się zgadzają" wycieka informacje o sekrecie. Użyj porównania timing-safe:
// Node
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
// Python
hmac.compare_digest(a, b)
// Ruby
ActiveSupport::SecurityUtils.secure_compare(a, b)
W praktyce atak jest trudny do przeprowadzenia na endpoint webhooka przez publiczny internet, ale bezpieczne porównanie jest tak tanie, że nie ma powodu, by go nie używać.
4. Niewalidowany timestamp
Jeśli podpis obejmuje tylko ciało, atakujący, który przechwyci prawidłowe żądanie, może je powtarzać w nieskończoność. Dostawcy rozwiązują to, dołączając timestamp do payloadu podpisu i okno tolerancji w dokumentacji (Stripe: 5 minut, GitHub: też krótkie). Twój handler powinien odrzucać każdy podpis z timestampem starszym niż tolerancja.
Pomiń ten krok, a zbudowałeś system podatny na replay, bez względu na to, jak doskonale działa HMAC.
Co naprawdę jest w nagłówku
Nagłówki dostawców niosą więcej niż tylko „podpis". Stripe wysyła t=1234567890,v1=abcdef...,v0=... — timestamp plus wiele wersji podpisu. GitHub wysyła sha256=abcdef... z prefiksem. Twilio włącza URL żądania do payloadu podpisu, ale wysyła sam podpis tylko w X-Twilio-Signature. Przeczytaj dokumentację dostawcy, z którym integrujesz, a potem implementuj pod dokładny format.
Testowanie lokalne czyni to debugowalnym
Weryfikacja podpisu to najgorszy rodzaj buga: cichy i trudny do odtworzenia na staging. Testowanie lokalne z tunelem i przechwytywaniem żądań daje dokładny payload, który zawiódł, dokładny nagłówek, który dotarł, i przycisk powtórki, by przetestować poprawkę bez niepokojenia dostawcy.
Gdy weryfikacja coś odrzuci, zaloguj tyle, by zdebugować: długość surowego ciała, odczytany nagłówek, obliczony podpis. Nie loguj sekretu. (Widzieliśmy, jak ludzie logują sekrety. Nie rób tego.)
Minimalny poprawny weryfikator
// Express + Node, generic HMAC SHA-256 hex provider
app.post(
'/webhooks/foo',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-foo-signature'];
const timestamp = req.headers['x-foo-timestamp'];
// 1. Reject old timestamps (5 min tolerance)
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send('stale');
}
// 2. Compute expected signature
const expected = crypto
.createHmac('sha256', process.env.FOO_SECRET)
.update(`${timestamp}.${req.body}`)
.digest('hex');
// 3. Timing-safe compare
if (!crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
)) {
return res.status(401).send('invalid');
}
// 4. Now safe to parse and process
const event = JSON.parse(req.body);
handleEvent(event);
res.status(200).end();
},
);
Gdy weryfikacja podpisu wciąż zawodzi
Jeśli wszystko powyższe jest poprawne, a wciąż zawodzi, sprawdź jeszcze trzy rzeczy: sekret ma końcowe białe znaki z pliku env, twój tunel przepisał nagłówek (większość nie, ale warto potwierdzić przechwytywaniem żądań), albo testujesz wobec sekretu live, podczas gdy dashboard pokazuje sekret testowy. Trafiliśmy na wszystkie trzy.
Po szczegóły specyficzne dla dostawców zajrzyj do przewodników Stripe, GitHub i Shopify. A jeśli patrzysz teraz na 401, przejdź do debugowania błędów 401 webhooka. Dołącz do listy oczekujących PortPreview, by mieć tunel + przechwytywanie, które to debugują.