Weryfikacja podpisu webhooka Stripe nie powiodła się — sprawdź dlaczego
constructEvent ze Stripe rzuca No signatures found matching the expected signature for payload, a Ty nie masz pojęcia, która z kilkunastu rzeczy poszła nie tak. Ta strona robi jedno: bierze trzy artefakty, które daje Ci Stripe — sekret whsec_, dokładne surowe body i nagłówek Stripe-Signature — przelicza HMAC i wskazuje tę jedną przyczynę, która faktycznie wszystko zepsuła.
W skrócie: w 9 przypadkach na 10 to surowe body (framework ponownie zserializował JSON przed weryfikacją) albo zły sekret podpisu (whsec_ z CLI zamiast tego z endpointu). Debugger poniżej odróżni jedno od drugiego po jednym wklejeniu.
Debuguj swój podpis Stripe
Stripe jest wybrany domyślnie. HMAC liczy się w Twojej przeglądarce za pomocą WebCrypto — Twój whsec_ nigdy nie opuszcza karty.
Anatomia nagłówka Stripe-Signature
Każdy webhook Stripe przychodzi z jednym nagłówkiem, który wygląda tak. Każde pole oddzielone przecinkiem ma swoje zadanie:
Stripe-Signature: t=1614556800, v1=5257a869e7ecebeda32affa62cdca3fa51cad7414a563a8c6e3c4b41c2b7f2c8, v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39
| Anatomia nagłówka Stripe-Signature | |
|---|---|
| t= | Timestamp Unix (w sekundach) wygenerowania podpisu przez Stripe. Wchodzi w skład tego, co jest podpisywane — i to z nim porównuje się 5-minutowe okno ochrony przed replay. |
| v1= | Podpis, który faktycznie weryfikujesz: HMAC-SHA256 z ciągu t + "." + rawBody, zakodowany w hex, z kluczem whsec_ Twojego endpointu. To jedyny schemat, który sprawdza constructEvent. |
| v0= | Przestarzały schemat testowy. Stripe nadal go wysyła, ale biblioteka ignoruje go przy weryfikacji — nie próbuj dopasowywać do v0. |
| , (przecinek) | Podczas rotacji sekretu Stripe podpisuje zarówno starym, jak i nowym sekretem, więc możesz zobaczyć dwie wartości v1=. constructEvent przechodzi, jeśli pasuje którakolwiek. |
Podpisywany ciąg to `${t}.${rawBody}` — nie samo body. Pomiń prefiks t. we własnym weryfikatorze, a każdy podpis zawiedzie, nawet z właściwym sekretem.
6 głównych przyczyn, w kolejności sprawdzania
Uszeregowane według tego, jak często są winowajcą. Każda ma znak rozpoznawczy, który odróżnia ją od pozostałych.
| # | Przyczyna | Charakterystyczny objaw | Rozwiązanie |
|---|---|---|---|
| 1 | Body to nie surowe bajty | Zawodzi na prawdziwych zdarzeniach, ale policzony HMAC jest blisko; przechodzi, jeśli wkleisz dokładny surowy ciąg do debuggera powyżej. | Przekaż constructEvent nietknięte body żądania, zanim dotknie go jakikolwiek parser JSON. |
| 2 | Zły sekret podpisu | Każde zdarzenie zawodzi, HMAC nawet nie przypomina v1. Często to whsec_ z CLI użyty do ruchu z dashboardu. | Skopiuj sekret ze strony endpointu w Dashboardzie (Developers → Webhooks), a nie ze stripe listen. |
| 3 | Middleware sparsował body wcześniej | Działa ze stripe trigger, ale na produkcji zwraca 400; express.json() w app.use Expressa uruchamia się przed Twoją trasą. | Podepnij express.raw({type:'application/json'}) tylko na trasie webhooka, przed globalnym parserem JSON. |
| 4 | Sekret z trybu testowego w trybie live (lub odwrotnie) | Podpis zawodzi tylko przy prawdziwych płatnościach, a przy zdarzeniach testowych przechodzi. whsec_ wygląda dobrze, ale należy do drugiego trybu. | Do ruchu live używaj whsec_ endpointu live; test i live mają każdy swój. |
| 5 | Brakujący lub obcięty nagłówek | sig jest undefined lub pusty; proxy albo CDN usunęło Stripe-Signature lub zmieniło go na małe litery. | Czytaj req.headers['stripe-signature'] (małymi literami w Node) i upewnij się, że proxy go przekazuje. |
| 6 | Sekret z innego endpointu | Jeden endpoint weryfikuje, drugi zwraca 400 — użyto tego samego whsec_ dla dwóch URL-i endpointów. | Każdy endpoint ma własny sekret podpisu. Przypisz sekret do URL-a, który Stripe faktycznie wywołuje. |
Poprawka surowego body, framework po frameworku
Przyczyna nr 1 to niemal zawsze to: Twój framework webowy odczytał, sparsował i ponownie zserializował JSON, zanim zobaczył go constructEvent. Ponownie zserializowany JSON ma inne białe znaki, więc HMAC przestaje się zgadzać. Oto jak przekazać każdemu frameworkowi nietknięte bajty.
| Framework | Dlaczego body jest zmieniane | Rozwiązanie |
|---|---|---|
| Express | express.json() parsuje i odrzuca surowy tekst w całej aplikacji. | express.raw({type:'application/json'}) na trasie webhooka, przed express.json(). |
| Next.js App Router | Route handlery nie parsują body za Ciebie. | const body = await request.text() — ten ciąg jest już surowy. |
| Next.js Pages Router | Wbudowany bodyParser przepisuje body. | export const config = { api: { bodyParser: false } }, a potem zbuforuj strumień. |
| Flask | request.json / request.form zużywają strumień i kodują go ponownie. | payload = request.get_data() — bajty, zanim dotkniesz .json. |
| Django | Dostęp do request.POST blokuje body. | request.body — odczytaj je najpierw, przed jakimkolwiek dostępem do formularza. |
| AWS Lambda / API Gateway | API Gateway może zakodować body w base64 albo mapping template może je przepisać. | Użyj integracji Lambda proxy; jeśli event.isBase64Encoded, zdekoduj event.body przed weryfikacją. |
// Trasa webhooka MUSI być przed express.json()
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(
req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
);
res.json({ received: true });
}
);
app.use(express.json()); // cała resztaexport async function POST(req: Request) {
const body = await req.text(); // surowe, nietknięte
const sig = req.headers.get('stripe-signature')!;
const event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
);
return Response.json({ received: true });
}import { buffer } from 'micro';
export const config = { api: { bodyParser: false } };
export default async function handler(req, res) {
const raw = await buffer(req);
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(
raw, sig, process.env.STRIPE_WEBHOOK_SECRET
);
res.json({ received: true });
}Wklej dokładne bajty do debuggera powyżej: jeśli podpis zgadza się dopiero po usunięciu końcowego znaku nowej linii albo przekodowanego pola, body zostało zmienione po drodze — a nie Twój sekret.
Tryb testowy a live: sekret, który wygląda dobrze, ale nie jest
Stripe daje Ci trzy różne wartości whsec_ i łatwo je pomylić. Pierwsza to sekret endpointu testowego, widoczny w Dashboardzie w trybie testowym. Druga to sekret endpointu live — osobna wartość tego samego endpointu przełączonego na live. Trzecia to sekret CLI, który wypisuje stripe listen, znowu inny i ważny tylko dla zdarzeń przekazywanych przez tę sesję. Wszystkie trzy dają wiarygodnie wyglądający HMAC, więc błąd jest ten sam, ogólny No signatures found — nic nie mówi o pomyleniu trybów. Znak rozpoznawczy: płatności testowe się weryfikują, prawdziwe dostają 400. Rozwiązanie: odczytaj sekret z dokładnie tego endpointu, w dokładnie tym trybie, który odbiera ruch.
5-minutowe okno ochrony przed replay, o którym wszyscy zapominają
constructEvent nie sprawdza tylko HMAC — odrzuca każde zdarzenie, którego timestamp t= jest starszy niż 300 sekund, rzucając Timestamp outside the tolerance zone. Daje to o sobie znać, gdy po kilku godzinach odtwarzasz przechwycone zdarzenie albo gdy zegar serwera się rozjechał. Podpis jest całkowicie poprawny; zawodzi wiek zdarzenia. Debugger powyżej pokazuje wiek timestampu, co pozwala odróżnić przeterminowany replay od prawdziwej niezgodności podpisu. Jeśli naprawdę potrzebujesz dłuższego okna, constructEvent przyjmuje czwarty argument tolerance w sekundach — ale najpierw napraw zegar.
Handler czy sekret? Wygeneruj zdarzenie, o którym wiesz, że jest poprawne
Gdy debugger mówi, że sekret jest dobry, a produkcja nadal zwraca 400, błąd tkwi w tym, jak Twój handler odczytuje body. Wyizoluj go: nasz Signer generuje payload podpisany jak przez Stripe sekretem, który kontrolujesz, więc możesz wysłać POST-em gwarantowanie poprawne zdarzenie prosto do lokalnego handlera. Jeśli przejdzie, Twój kod weryfikacji jest w porządku, a problemem jest sekret albo middleware. Jeśli nie, handler zmienia body — wróć do tabeli o surowym body powyżej.
Lista kontrolna diagnostyki
Wykonaj te kroki po kolei, gdy następnym razem constructEvent rzuci błąd.
- Wklej niepasujący whsec_, surowe body i Stripe-Signature do debuggera powyżej — wskaże przyczynę, zanim zaczniesz czytać dalej.
- Upewnij się, że body docierające do constructEvent to nietknięty surowy ciąg, a nie obiekt sparsowany i ponownie zserializowany.
- Skopiuj sekret podpisu prosto ze strony endpointu w Dashboardzie — nie ze stripe listen, nie z innego endpointu.
- Sprawdź, czy dla ruchu live używasz sekretu live, a dla ruchu testowego sekretu testowego.
- Zaloguj req.headers['stripe-signature'] i sprawdź, czy nagłówek jest obecny i nie został obcięty przez proxy.
- Sprawdź, czy timestamp t= zdarzenia nie jest starszy niż 5 minut (przeterminowany replay lub rozjechany zegar).
Przechwyć i odtwórz prawdziwe zdarzenie, które zawodzi
Nadal nic? Skieruj Stripe na Relay Webhook Toolkit albo URL do przechwytywania, złap dokładnie to żądanie, które zawodzi, i obejrzyj surowe body oraz nagłówki bajt po bajcie — a potem odtwarzaj je na swoim handlerze tyle razy, ile potrzebujesz, bez czekania na kolejne zdarzenie live.
Najczęstsze pytania
Dlaczego mój webhook Stripe działa lokalnie, a na produkcji nie?
Prawie zawsze chodzi o surowe body. Lokalnie ze stripe listen CLI przekazuje nietknięte bajty, ale Twój produkcyjny middleware (express.json() w Expressie, bodyParser w Pages Router, mapping template w API Gateway) parsuje i ponownie serializuje JSON przed weryfikacją. Przekaż constructEvent surowe body żądania, a przejdzie.
Co oznacza „No signatures found matching the expected signature for payload”?
Policzony HMAC nie pasuje do żadnego v1= wysłanego przez Stripe. Dwie typowe przyczyny: body zostało zmienione przed weryfikacją albo sekret podpisu jest zły (często whsec_ z CLI zamiast tego z endpointu). Wklej wszystkie trzy do debuggera powyżej, a powie Ci, która.
Czy mogę używać tego samego sekretu podpisu w trybie testowym i live?
Nie. Każdy endpoint ma osobny whsec_ dla trybu testowego i live, a CLI wypisuje trzeci. Sekret testowy daje wiarygodnie wyglądający HMAC, który i tak zawodzi przy zdarzeniach live, z tym samym ogólnym błędem. Dopasuj sekret do trybu, który odbiera ruch.
Jak naprawić „Timestamp outside the tolerance zone”?
Podpis jest poprawny, ale t= zdarzenia jest starszy niż 300 sekund — zwykle to odtworzone przechwycone zdarzenie albo rozjechany zegar serwera. Napraw zegar albo odtwórz świeże zdarzenie. W ostateczności przekaż większą tolerancję (w sekundach) jako czwarty argument constructEvent.
Czy v0 w nagłówku Stripe-Signature służy do weryfikacji?
Nie. constructEvent sprawdza tylko v1= (HMAC-SHA256 z t.rawBody). v0= to przestarzały schemat testowy, który Stripe nadal wysyła, ale biblioteka go ignoruje. Jeśli Twój własny weryfikator porównuje z v0, to jest Twój błąd.
Czy ten debugger podpisów Stripe wysyła gdzieś mój sekret?
Nie. HMAC liczy się w Twojej przeglądarce za pomocą WebCrypto. Twój whsec_, body i nagłówek nigdy nie opuszczają karty — nic nie jest wysyłane, logowane ani zapisywane. Odłącz się od sieci, a nadal będzie działać.