Skip to content
Powrót do bloga
Bezpieczeństwo

Weryfikacja podpisu webhooka nie działa: przyczyny i rozwiązania

Weryfikacja podpisu webhooka nie działa? Zwykle chodzi o surowe body, kodowanie skrótu lub brak prefiksu znacznika czasu. Sprawdź to darmowym narzędziem HMAC.

15 min czytania

Weryfikacja podpisu webhooka nie działa? Znajdź przyczynę

Błąd webhook signature verification failed znaczy dokładnie jedno: digest (skrót kryptograficzny) policzony przez kod nie jest równy digestowi z nagłówka żądania. To cała treść tego komunikatu. Nie mówi nic o uprawnieniach, nic o wygaśnięciu i prawie nigdy nie jest to błąd w SDK dostawcy. Coś różni się między bajtami, z których hash policzył dostawca, a bajtami, z których policzył go kod weryfikujący. W polskich zespołach mówi się na to raz „podpis”, raz „sygnatura”, więc szukając opisu problemu, warto sprawdzić obie nazwy: chodzi o to samo pole nagłówka.

O wyniku decydują cztery wejścia: które bajty zostały podpisane, jakich bajtów klucza użyto, który algorytm hash się wykonał i w jakim kodowaniu tekstowym doszło do porównania. Pomyłka w którymkolwiek z nich wygląda identycznie. Komunikat nie podpowiada, o który chodziło, więc praca polega na zawężaniu przestrzeni wejść, a nie na uważniejszym czytaniu błędu.

Punkt startowy wybiera się tak:

Podpis się nie zgadza? Trzy gałęzie:
├─ Framework sparsował JSON, zanim kod go zobaczył?     → sekcja 3
├─ Wartość nagłówka ma prefiks albo wygląda na base64?  → sekcja 4
└─ Nagłówek dostawcy zawiera timestamp?                 → sekcja 2

Każda sekcja poniżej kończy się czymś, co da się uruchomić na własnym payloadzie.

1. Co znaczy niezgodność podpisu

Weryfikacja to porównanie dwóch ciągów bajtów. Kiedy zawodzi, zepsuta jest dokładnie jedna z czterech rzeczy, a te cztery są od siebie niezależne.

Które bajty zostały podpisane. Dostawca policzył hash z konkretnej sekwencji bajtów. Może to być samo body żądania, a może timestamp przyklejony na początku body. Jeśli framework sparsował JSON i podał gotowy obiekt, tych bajtów już nie ma i nie da się ich wiarygodnie odtworzyć. To sekcja 3 i z dużą przewagą najczęstsza przyczyna.

Jakich bajtów klucza użyto. Ten sam ciąg sekretu można odczytać jako tekst UTF-8, jako hex albo jako base64, a każde odczytanie daje inny klucz. Osobny klucz daje też sekret z dodatkowym znakiem nowej linii, który zachował loader konfiguracji. W tym wymiarze kryje się jeszcze jedna awaria: sekret może być całkiem innym sekretem, a nie błędnym odczytem właściwego. To sekcja 6.

W jakim kodowaniu doszło do porównania. Dla SHA-256 digest to 32 surowe bajty. Hex i base64 to dwa sposoby zapisania tych samych bajtów jako tekst i nigdy nie wyglądają podobnie. Porównanie jednego z drugim daje trwałą niezgodność podpisu HMAC, mimo że bajty pod spodem się zgadzają.

Który algorytm hash się wykonał. Większość dostawców używa SHA-256 i pisze o tym wprost, więc ten wymiar zwykle nic nie kosztuje. Wyjątkiem wartym znajomości jest GitHub: każde dostarczenie niesie X-Hub-Signature (HMAC-SHA1) obok X-Hub-Signature-256 (HMAC-SHA256), a własna dokumentacja GitHuba mówi o nagłówku SHA-1, że „jest dołączany wyłącznie ze względów historycznych”, i zaleca wariant 256. Przy odczycie złego nagłówka zdradza to najpierw długość, a nie bajty. Body z sekcji 2 podpisane tym samym sekretem pod SHA-1 to sha1=ba2954d180839d8170b08b32cd38483775aaae96, czyli 40 znaków hex wobec 64 znaków digestu SHA-256.

W diagnozie te cztery wymiary trzeba trzymać osobno. Pojedynczy wymiar najszybciej izoluje się, licząc digest poza aplikacją, z wejść, nad którymi ma się pełną kontrolę: wystarczy wkleić body i sekret do generatora HMAC i zobaczyć, co wyjdzie. Narzędzie działa w całości w przeglądarce i sekret nie opuszcza strony, więc można tam wkleić produkcyjny sekret podpisujący. HMAC uruchamia ten sam prymityw SHA-256 co zwykły hash SHA-256, tylko z kluczem w postaci sekretu. Jeśli wartość dostawcy da się odtworzyć ręcznie, kryptografia jest w porządku, a błąd siedzi w obsłudze żądania.

2. Co naprawdę podpisują czterej najwięksi dostawcy

Założenie, które topi większość integracji, brzmi: każdy dostawca podpisuje body żądania i nic więcej. Dwóch z czterech największych tak nie robi. Oto z czego każdy z nich liczy hash, zgodnie z jego dokumentacją:

DostawcaNagłówekPodpisywany ciągKodowaniePrefiks wartościSekretTolerancja timestampu
StripeStripe-Signature{timestamp} + . + rawBodyhext=…,v1=…,v0=…sekret podpisujący endpointu (prefiks whsec_)5 minut (300 sekund)
GitHubX-Hub-Signature-256rawBody (bez prefiksu)hexsha256=token sekretu webhookabrak (timestamp nie jest wysyłany)
SlackX-Slack-Signature + X-Slack-Request-Timestampv0: + {timestamp} + : + rawBodyhexv0=sekret podpisujący5 minut
ShopifyX-Shopify-Hmac-SHA256rawBodybase64brakclient secret aplikacji (nie osobny sekret webhooka)brak

Te cztery przypadki obejmują trzy niezależne osie. Podpisywany ciąg to albo samo body, albo konkatenacja z timestampem, a różni się nawet separator: Stripe używa ., a Slack :. Kodowanie jest hex u trzech i base64 u jednego. Sekret pochodzi z dedykowanego poświadczenia webhooka u trzech, a u Shopify z client secret aplikacji. Ten ostatni szczegół myli ludzi najczęściej, bo w panelu administracyjnym jest pole opisane „webhook”, które nie jest tym, o co chodzi.

Żeby te różnice były konkretne, oto jedno body podpisane czterema sposobami tym samym sekretem:

body   : {"id":42,"event":"user.created"}
secret : whsec_test_secret
ts     : 1700000000
PostaćWartość
styl GitHubasha256=09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b
styl ShopifyCd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs=
styl Stripe’at=1700000000,v1=4b56de5a58122bab8ebbadbed663fbc17d810096d57498f5b24a72f5123b2375
styl Slackav0=3faf37337484c62dcd1a6c1ff308d1345c31291e4aac9b44554a99e8e35a1f9c

Pierwsze dwa wiersze trzeba czytać razem, bo to ten sam 32-bajtowy digest zapisany dwa razy. Sześćdziesiąt cztery znaki hex albo czterdzieści cztery znaki base64 razem z dopełnieniem. Nic w tych dwóch ciągach nie sugeruje, że są równe — dlatego porównywanie między kodowaniami daje niezgodność, która przeżywa każde sprawdzenie w duchu „ale sekret jest przecież poprawny”.

Dwa ostatnie wiersze dowodzą drugiej połowy tezy. To samo body, ten sam sekret, ten sam algorytm, a żaden z digestów nie przypomina tego z GitHuba, bo ciąg, z którego liczy się hash, zaczyna się teraz od timestampu. Większość zgłoszeń błędu stripe webhook signature verification failed sprowadza się do tego wiersza: kod policzył hash z samego body i nigdy nie dokleił z przodu wartości t oraz kropki. Wystarczy odtworzyć wszystkie cztery w generatorze HMAC, zmieniając tylko pole wiadomości i przełączając format wyjścia. Mechanizm przestaje wtedy być abstrakcyjny.

Praktyczna konsekwencja kolumny z timestampem: digest Stripe’a albo Slacka jest ważny tylko kilka minut, więc nie da się przechwycić podpisu dziś i odtworzyć go w teście nazajutrz. Podpisy GitHuba i Shopify są stabilne na zawsze, więc diagnozuje się je znacznie łatwiej, ale o ochronę przed replay trzeba zadbać samodzielnie.

3. Problem surowego body

Większość zgłoszeń webhook signature verification failed prowadzi do frameworka, który przeczytał i sparsował body, zanim handler w ogóle je zobaczył.

Framework już zniszczył bajty

Frameworki webowe są zbudowane tak, żeby oszczędzić programiście parsowania. Właśnie ta wygoda psuje weryfikację podpisu, bo w momencie uruchomienia handlera oryginalnych bajtów już nie ma.

express.json() czyta strumień żądania, parsuje go i podmienia req.body na obiekt JavaScriptu. Strumień jest skonsumowany i nie da się go przeczytać ponownie. W FastAPI zadeklarowanie modelu Pydantic albo parametru body typu dict sprawia, że framework czyta i parsuje jeszcze przed wejściem do funkcji. Rails wypełnia params z body JSON przez middleware działające przed akcją kontrolera. Konwerter Jackson w Springu zamienia body w klasę DTO, a domyślnie strumień wejściowy HttpServletRequest pod spodem da się przeczytać tylko raz.

Nic z tego nie jest błędem. Każdy z tych mechanizmów robi dokładnie to, do czego został skonfigurowany. Problem polega na tym, że podpis obejmuje bajty, obiekt bajtami nie jest, a zamiana obiektu z powrotem w bajty to inna operacja niż ta, którą wykonał dostawca.

Dlaczego ponowna serializacja czasem działa i dlaczego to pułapka

Standardowa porada mówi, że ponowna serializacja zmienia bajty. To niepełna prawda, a brakująca połowa tłumaczy, dlaczego ta awaria jest tak trudna do zdiagnozowania. Czasem nie zmienia zupełnie nic.

Oto JSON.stringify(JSON.parse(body)) === body zmierzone dla różnych postaci payloadu:

Postać payloaduBajty po round-tripieZmiana
{"id":42,"event":"user.created"}identycznebrak, i dlatego lokalne testy przechodzą
{"amount":1.0}zmienione{"amount":1}
{"n":1e3}zmienione{"n":1000}
{"id":12345678901234567890}zmienione{"id":12345678901234567000} (utrata precyzji)
{"name":"caf\u00e9"}zmienione{"name":"café"} (6 bajtów staje się 2)
{"a":1}\nzmienionekońcowy znak nowej linii zjedzony
{ "a" : 1 }zmienionewewnętrzne białe znaki zjedzone
{"v":-0.0}zmienione{"v":0}
{"p":0.1000000000000000055511151231257827}zmienione{"p":0.1}

Najważniejszy jest tu pierwszy wiersz. Płaski obiekt z liczbą całkowitą i krótkim ciągiem ASCII przechodzi round-trip bajt w bajt, więc weryfikator działający w schemacie „sparsuj, a potem zserializuj z powrotem” przechodzi każdy test napisany na takiej próbce. Potem następuje wdrożenie i pierwszy payload z kwotą 1.0, z ID powyżej 2^53 albo z nazwiskiem klienta zawierającym znak diakrytyczny — ten już nie przechodzi. Nie wszystkie. Tylko te.

To właśnie mechanizm stojący za „lokalnie działa, na produkcji sporadyczne 401” i jest on znacznie gorszy niż weryfikator, który zawodzi zawsze. Weryfikator, który zawodzi za każdym razem, zostaje naprawiony w ciągu godziny. Ten, który zawodzi na 3% zdarzeń, zwala się na dostawcę, ponawia, eskaluje i toleruje tygodniami. Jeśli odsetek awarii jest większy od zera i mniejszy od stu procent, tę tabelę trzeba przejrzeć jako pierwszą.

Kolejność kluczy to przyczyna, której wszyscy się spodziewają, i w praktyce najmniej prawdopodobna, bo JSON.parse zachowuje kolejność wstawiania dla kluczy tekstowych. Prawdziwymi winowajcami są liczby i białe znaki.

Jak dobrać się do surowego body w każdym frameworku

Express, z parserem specyficznym dla trasy zarejestrowanym przed globalnym parserem JSON:

const express = require('express');
const crypto = require('crypto');
const app = express();

// Ta trasa musi być zarejestrowana PRZED app.use(express.json()).
// body-parser oznacza żądanie jako sparsowane, więc późniejszy raw() cicho zwraca {}.
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body; // Buffer, nie obiekt
  const digest = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(raw) // hash liczony wprost z Buffera, bez toString()
    .digest('hex');
  console.log('bytes:', raw.length, 'digest:', digest);
  res.sendStatus(200);
});

app.use(express.json()); // każda inna trasa nadal dostaje sparsowany JSON
app.listen(3000);

Jeśli nie da się zmienić kolejności middleware, kopię można zachować w trakcie samego parsowania:

app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = Buffer.from(buf); },
}));

FastAPI. Starlette buforuje body, więc await request.body() zwraca oryginalne bajty nawet w handlerze, który dodatkowo przyjmuje sparsowany model:

import hashlib, hmac, os
from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

@app.post("/webhooks/github")
async def github(request: Request):
    raw = await request.body()  # bajty, dokładnie tak jak przyszły
    expected = "sha256=" + hmac.new(
        os.environ["WEBHOOK_SECRET"].encode("utf-8"), raw, hashlib.sha256
    ).hexdigest()
    received = request.headers.get("X-Hub-Signature-256", "")
    if not hmac.compare_digest(expected, received):
        raise HTTPException(status_code=401, detail="bad signature")
    return {"ok": True}

Rails, gdzie request.raw_post oddaje niesparsowane body jako string:

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def shopify
    raw = request.raw_post
    digest = Base64.strict_encode64(
      OpenSSL::HMAC.digest('sha256', ENV['SHOPIFY_CLIENT_SECRET'], raw)
    )
    unless OpenSSL.secure_compare(digest, request.headers['X-Shopify-Hmac-SHA256'].to_s)
      return head :unauthorized
    end
    head :ok
  end
end

Go, gdzie body czyta się samodzielnie i trzeba pamiętać, że potem jest już opróżnione:

func handler(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "unreadable body", http.StatusBadRequest)
		return
	}
	mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET")))
	mac.Write(raw)
	expected := mac.Sum(nil)

	got, err := hex.DecodeString(
		strings.TrimPrefix(r.Header.Get("X-Hub-Signature-256"), "sha256="))
	if err != nil || !hmac.Equal(expected, got) {
		http.Error(w, "bad signature", http.StatusUnauthorized)
		return
	}
	// Unmarshal z raw, nigdy z r.Body — nie zostały w nim żadne bajty.
	w.WriteHeader(http.StatusOK)
}

Spring, gdzie poproszenie o byte[] całkowicie pomija Jacksona:

@PostMapping(path = "/webhooks/github", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> github(@RequestBody byte[] payload,
                                   @RequestHeader("X-Hub-Signature-256") String header)
        throws GeneralSecurityException {
  Mac mac = Mac.getInstance("HmacSHA256");
  mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
  String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(payload));
  boolean ok = MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
                                     header.getBytes(StandardCharsets.UTF_8));
  return ok ? ResponseEntity.ok().build()
            : ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}

ContentCachingRequestWrapper jest alternatywą, gdy sprawdzenie musi wykonać filtr, a sygnatury kontrolera nie da się zmienić. Ma własną pułapkę: getContentAsByteArray() zwraca bajty tylko wtedy, gdy coś dalej w łańcuchu już przeczytało strumień, więc wywołanie przed chain.doFilter(...) daje pustą tablicę.

4. Niezgodności kodowania: hex, base64 i sam klucz

Między digestem a wartością nagłówka siedzą trzy osobne decyzje o kodowaniu i każda z nich potrafi samodzielnie zepsuć porównanie.

Kodowanie digestu. Wyjście HMAC-SHA256 ma 32 bajty. Zapisane jako hex małymi literami to 64 znaki; zapisane jako standardowe base64 to 44 znaki razem z dopełnieniem =. Dwa wiersze z sekcji 2 to jeden digest zapisany na oba sposoby:

KodowanieZnakiTe same 32 bajty zapisane jako
hex6409dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b
base6444Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs=

Szybka heurystyka, gdy przed oczami jest nieznany nagłówek: jeśli wartość to 64 znaki z zakresu 0-9a-f, jest to hex. Jeśli to 44 znaki kończące się na = albo zawierające +, / bądź wielkie litery, jest to base64. Kiedy zamiast zgadywać chce się mieć pewność, wystarczy przepuścić wartość base64 przez dekoder Base64 i sprawdzić, czy wychodzą z niej 32 bajty; jeśli tak, oba ciągi opisują ten sam digest, a porównywane były formaty tekstowe, nie podpisy.

Prefiks wartości. GitHub wysyła sha256= przed hexem. Slack wysyła v0=. Stripe zawija wszystko w listę par key=value rozdzielonych przecinkami. Żaden z tych znaków nie jest częścią digestu, więc albo trzeba obciąć prefiks z nagłówka, albo dodać go do własnej wartości. Pominięcie obu kroków to najczęstsza przyczyna, dla której poprawna implementacja raportuje niezgodność podpisu HMAC, a w Node nie raportuje nawet niezgodności, co wyjaśnia sekcja 7.

Kodowanie klucza. Sekret też jest bajtami, a ten sam ciąg odczytany jako UTF-8, hex albo base64 daje trzy różne klucze. Dostawcy, którzy wydają tekstowy token w rodzaju whsec_..., oczekują UTF-8, ale wiele systemów wewnętrznych rozprowadza sekrety w base64 lub hex, które trzeba zdekodować przed podpisaniem. Ten tryb awarii ma identyczny kształt jak jego wersja w JWT i jest szczegółowo opisany w tekście JWT „invalid signature”: wszystkie przyczyny i sposoby naprawy, razem z tym, jak rozpoznać, czy dany sekret jest w base64, czy w zwykłym tekście.

5. Timestamp, tolerancja i okna replay

Można policzyć digest, który zgadza się idealnie, i nadal zostać odrzuconym. Dostawcy dołączający timestamp oczekują, że zostanie sprawdzony, a przestarzały timestamp to poprawny podpis, który i tak trzeba odrzucić.

DostawcaGdzie siedzi timestampOkno
Stripet= wewnątrz Stripe-Signature5 minut (300 sekund)
Slacknagłówek X-Slack-Request-Timestamp5 minut
GitHubnie jest wysyłanynie dotyczy
Shopifynie jest wysyłanynie dotyczy

Pomyłka w oknie boli w obie strony. Za hojne okno pozwala odtwarzać przechwycone żądanie tak długo, jak na to pozwala konfiguracja, co niweczy większość sensu sprawdzania timestampu. Za ciasne zaczyna odrzucać prawdziwe dostarczenia przy zwykłym rozjeździe zegarów. Pięć minut wybrali obaj dostawcy i skopiowanie tej wartości to sensowny domyślny wybór.

Przed poszerzeniem tolerancji warto sprawdzić zegar. Obrazy kontenerów nie uruchamiają NTP, a maszyna wirtualna wznowiona ze snapshotu może być kilka minut za czasem zegarowym, przy czym w logach nie ma o tym ani słowa. Host z systematycznym dryfem produkuje awarie, które zaczynają się jako sporadyczne, a potem stają się całkowite, i to czyta się jak regresja w kodzie, choć nią nie jest.

Drugi błąd zegarowy to niezgodność jednostek. Każdy dostawca z tabeli wysyła sekundy epoch. Porównanie takiej wartości z milisekundową, na przykład z Date.now() w JavaScripcie, daje różnicę około tysiąc razy większą niż prawdziwy wiek zdarzenia, więc każde zdarzenie wypada poza każdym sensownym oknem. Objaw: sprawdzenie tolerancji odrzuca sto procent dostarczeń, choć digest się zgadza. Jeśli nie ma pewności, o którą jednostkę chodzi, zdradza ją długość, a tekst sekundy epoch a milisekundy opisuje przeliczenia i związane z nimi pułapki strefowe.

Do zbudowania podpisywanego ciągu trzeba użyć surowego ciągu timestampu z nagłówka, a nie sparsowanej i przeformatowanej liczby. Sparsowanie 1700000000 do liczby zmiennoprzecinkowej i wypisanie z powrotem może dać 1700000000.0, a to już inna sekwencja bajtów.

6. Zły sekret i sekrety, które podlegają rotacji

Przed zagłębianiem się w kodowania warto wykluczyć najprostszą przyczynę: sekret może po prostu nie być tym właściwym. Dokumentacja Stripe’a mówi wprost, że „Stripe generuje unikalny klucz sekretny dla każdego endpointu”, a jeśli ten sam URL wskaże się zarówno kluczem testowym, jak i produkcyjnym, to „sekret jest inny dla każdego z nich”. Wynikają z tego trzy wersje jednej pomyłki.

Tryb testowy i tryb produkcyjny mają osobne sekrety, więc wartość skopiowana przy panelu przełączonym w tryb testowy nie przejdzie żadnego produkcyjnego dostarczenia. Osobny sekret ma też każdy endpoint, a dokumentacja dodaje, że „przy wielu endpointach trzeba pobrać sekret dla każdego, na którym mają być weryfikowane podpisy”: wystarczy skierować dwa endpointy do jednego handlera z jednym sekretem w środowisku, a połowa ruchu przestanie przechodzić. Do tego stripe listen wypisuje sekret podpisujący dla lokalnego przekierowania z CLI, a to osobny endpoint od czegokolwiek zarejestrowanego w panelu, więc te dwie wartości nie są wymienne.

Żadna z tych sytuacji nie wygląda z zewnątrz na błąd kodowania. Digest jest poprawnie zbudowany, porównanie jest prawidłowe, a wartość w środowisku to prawdziwy sekret Stripe’a, tylko nie ten, którym podpisano to konkretne dostarczenie.

Rotacja to ten sam wymiar, tylko ruszający się pod stopami. Najmniej przypomina problem z kodowaniem i najczęściej bywa błędnie diagnozowana jako błąd w kodzie. W kodzie nic się nie zmieniło, wczoraj weryfikacja działała, a teraz zawodzi część zdarzeń.

Okno nakładania się jest zamierzone. Stripe utrzymuje stary sekret endpointu jako ważny do 24 godzin po rotacji, a w tym czasie nagłówek Stripe-Signature niesie po jednym podpisie v1 dla każdego aktywnego sekretu. Shopify idzie w drugą stronę: po rotacji może minąć nawet godzina, zanim zacznie liczyć digesty nowym sekretem, więc w międzyczasie potrzebny jest stary.

Zachowanie Stripe’a wywraca kod, bo nagłówek wygląda, jakby zawierał jeden podpis. Podzielenie po , i wzięcie pierwszego napotkanego v1 działa dokładnie do momentu, w którym są dwa. Od tej chwili trafienie wychodzi w około połowie przypadków, zależnie od tego, który sekret podpisał które zdarzenie. Trzeba przejść po wszystkich:

const crypto = require('crypto');

function verifyStripe(header, rawBody, secret, toleranceSec = 300) {
  let t = null;
  const v1 = [];
  for (const pair of header.split(',')) {
    const idx = pair.indexOf('=');
    const key = pair.slice(0, idx);
    const value = pair.slice(idx + 1);
    if (key === 'v1') v1.push(value);
    else if (key === 't') t = value; // zachowaj oryginalny ciąg
  }
  if (t === null || v1.length === 0) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (!Number.isFinite(age) || age > toleranceSec) return false;

  const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
  const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest();

  return v1.some((sig) => {
    const received = Buffer.from(sig, 'hex');
    return received.length === expected.length &&
      crypto.timingSafeEqual(received, expected);
  });
}

Poza samą pętlą liczą się tam dwa szczegóły. Timestamp wchodzi do podpisywanego payloadu jako ten ciąg, w którym przyszedł, a body jest konkatenowane jako bajty, nie przez interpolację w szablonie, bo ta najpierw zdekodowałaby je jako UTF-8.

Ten sam schemat obowiązuje przy rotacji po własnej stronie: przez czas nakładania się akceptuje się i stary, i nowy sekret, a potem stary odpada. Nowa wartość potrzebuje pełnej entropii, więc lepiej ją wygenerować niż wpisać z klawiatury, na przykład generatorem sekretów podpisujących dla 256-bitowej wartości losowej.

7. Porównywanie podpisów bez wycieku czasowego

Kiedy są już dwa digesty, sposób ich porównania jest decyzją bezpieczeństwa. Równość ciągów zwraca wynik, gdy tylko natrafi na różniący się bajt, więc czas wykonania ujawnia, ile początkowych bajtów było poprawnych. Atakujący, który może wysłać wiele żądań, odtwarza w ten sposób poprawny podpis bajt po bajcie. Przez internet to wolne i głośne, w sieci lokalnej całkowicie praktyczne.

Każde środowisko uruchomieniowe dostarcza porównanie o stałym czasie:

JęzykPorównanie o stałym czasieGdy długości się różnią
Nodecrypto.timingSafeEqual(a, b)rzuca wyjątek
Pythonhmac.compare_digest(a, b)zwraca False
Gohmac.Equal(a, b)zwraca false
PHPhash_equals($known, $user)zwraca false
RubyOpenSSL.secure_compare(a, b)zwraca false

Z tej ostatniej kolumny bierze się cała klasa mylących incydentów. Node jest tu odstępstwem i nie zawodzi grzecznie:

RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length

Ten wyjątek odpala przy błahej pomyłce. Digest SHA-256 w hex ma 64 znaki. Wartość w X-Hub-Signature-256 ma 71, bo sha256= to siedem znaków. Wystarczy zapomnieć o obcięciu prefiksu i oba bufory mają różne długości, więc timingSafeEqual rzuca wyjątek, zamiast zwrócić false. Jeśli nikt go nie przechwyci, wychodzi z handlera, a Express zamienia go w 500.

Z zewnątrz wygląda to na coś zupełnie innego. Szuka się odpowiedzi 401 Unauthorized od webhooka, a przychodzi błąd serwera, więc idzie się czytać handler, wywołanie bazy, dyspozytor zdarzeń. Prawdziwy błąd jest jedną linię nad porównaniem. Porównanie 64-znakowego digestu hex z 44-znakowym base64 rzuca wyjątek z tego samego powodu, co znaczy, że niezgodność kodowania w Node też wychodzi jako 500, a nie jako czyste odrzucenie.

Naprawa polega na samodzielnym sprawdzeniu długości i zwróceniu false:

function safeEqualHex(receivedHex, expectedHex) {
  const a = Buffer.from(receivedHex, 'hex');
  const b = Buffer.from(expectedHex, 'hex');
  if (a.length !== b.length) return false; // zabezpieczenie przed wywołaniem
  return crypto.timingSafeEqual(a, b);
}

Wyciek długości jest nieszkodliwy: długość digestu ustala algorytm i nie jest tajna. Wyciekać nie może to, który prefiks się zgodził. Zakładka weryfikacji w generatorze HMAC wlicza różnicę długości do tego samego akumulatora o stałym czasie, zamiast wychodzić przedwcześnie, więc rozbieżność długości wraca jako zwykłe false, a nie jako wyjątek, i wartość nagłówka można sprawdzić z policzonym digestem bez pisania kodu na jeden raz.

8. Kiedy warstwa transportowa zmieniła bajty

Podpisywany ciąg, surowe body, kodowania, zegar i rotacja są już wykluczone. Zostaje możliwość, że bajty docierające do procesu nie są bajtami, które wyszły od dostawcy.

Kompresja. Dostawca albo proxy może wysłać body spakowane gzipem z Content-Encoding: gzip. Podpis obejmuje nieskompresowany payload, więc hash trzeba policzyć po dekompresji. Część frameworków rozpakowuje przejrzyście, część podaje bajty skompresowane, a sygnał to body, które w logu wygląda jak binarne śmieci.

Transfer fragmentowany. Przy Transfer-Encoding: chunked nie ma Content-Length, a kod, który ufa temu nagłówkowi przy wymiarowaniu bufora odczytu, obcina body. Digest z obciętego body jest poprawnym nonsensem: nigdy się nie zgodzi, a nic nie wygląda na zepsute.

Proxy i WAF-y. Każda warstwa, która czyta i przepisuje body, może je zmienić. AWS API Gateway potrafi zakodować body w base64, zanim dotrze ono do Lambdy, więc trzeba je zdekodować przed policzeniem hasha. Load balancery aplikacyjne, siatki usług i firewalle aplikacji webowych też potrafią znormalizować albo przekodować payload. Test polega na porównaniu długości w bajtach, którą widzi handler, z Content-Length przysłanym przez dostawcę.

Kodowanie znaków i BOM. Payloady mogą zawierać znaki spoza ASCII, a dokumentacja GitHuba wprost mówi, że payload należy traktować jako UTF-8. Zdekodowanie body do ciągu w złym zestawie znaków i zakodowanie z powrotem niszczy każdy znak wielobajtowy. Znacznik kolejności bajtów UTF-8, EF BB BF, doklejony z przodu przez życzliwy edytor albo serializator, dodaje trzy bajty, których nikt nie podpisał.

Znaki końca linii i zabłąkane białe znaki. Body, które przeszło przez plik otwarty w trybie tekstowym, może przyjść z LF przepisanym na CRLF. Warto też przeczytać specyfikację dostawcy pod kątem dokładnego podpisywanego ciągu: niektórzy dopisują własny znak, a Typeform to udokumentowany przypadek, w którym końcowy znak nowej linii jest częścią tego, z czego liczy się hash. Kiedy dokumentacja dostawcy wspomina o jakimkolwiek dodatkowym znaku, trzeba to brać dosłownie.

9. Powtarzalny przepływ diagnostyczny

Kroki trzeba wykonywać po kolei. Każdy albo znajduje błąd, albo eliminuje gałąź, a wczesne zatrzymanie jest tu celem.

  1. Zaloguj surowe bajty przed uruchomieniem jakiegokolwiek middleware. Zapisz body do pliku albo zaloguj jego długość w bajtach wraz z SHA-256, z najwcześniejszego punktu cyklu życia żądania, do jakiego da się dotrzeć. Sama długość rozstrzyga zaskakująco wiele przypadków: wartość o jeden większa od oczekiwanej to końcowy znak nowej linii, o trzy większa to BOM.
  2. Policz digest ręcznie. Wklej dokładnie te bajty i sekret do generatora HMAC, wybierz SHA-256 i ustaw format wyjścia zgodny z nagłówkiem. To najbardziej opłacalny krok, bo dzieli problem czysto na dwie części.
  3. Porównaj wartość policzoną ręcznie z nagłówkiem. Równość znaczy, że bajty i sekret są poprawne, a błąd siedzi gdzieś w ścieżce kodu; wtedy trzeba przeczytać własne porównanie. Brak równości znaczy, że jedno z wejść jest złe, więc idź dalej.
  4. Sprawdź podpisywany ciąg w tabeli z sekcji 2. Czy ten dostawca dokleja timestamp z przodu? Jakim separatorem? Dodaj prefiks w narzędziu i przelicz ponownie.
  5. Przełącz kodowanie digestu. Przelicz jako hex i jako base64, a potem porównaj oba z nagłówkiem. 44-znakowa wartość nagłówka z = na końcu jest w base64, niezależnie od tego, co założył kod.
  6. Przełącz kodowanie klucza. Spróbuj sekretu jako tekstu, potem jako hex, potem jako base64. Jedno z tych trzech zwykle daje trafienie, a to mówi, czego oczekuje dostawca.
  7. Sprawdź zegar i stan rotacji. Porównaj czas serwera ze znanym źródłem, potwierdź, że w obiegu są sekundy epoch, i zajrzyj do panelu dostawcy po rotację z ostatnich 24 godzin.

Dwa nawyki bardzo przyspieszają tę pętlę. Po pierwsze, przechwyć jeden niedziałający payload i pracuj na nim offline, zamiast czekać na kolejne dostarczenie. Po drugie, odtwarzaj to przechwycone body na własnym endpoincie ze stałym podpisem, żeby wejście nie zmieniało się między próbami. Generator poleceń cURL składa żądanie z dokładnymi nagłówkami i body czytanym z pliku, co utrzymuje bajty stabilne między uruchomieniami. Możliwość odtworzenia awarii na żądanie zamienia sporadyczne zgłoszenie webhook signature verification failed w naprawę na pięć minut.

Jeśli zgłoszenie do supportu jest nadal konieczne, dołącz długość w bajtach body, z którego liczony był hash, wartość nagłówka co do znaku, użytą konstrukcję podpisywanego ciągu i kodowanie digestu. Nigdy nie dołączaj samego sekretu.

FAQ

Dlaczego podpis webhooka działa lokalnie, a na produkcji zawodzi?

Testowy payload najprawdopodobniej przechodzi round-trip JSON bez zmian, więc ponowna serializacja jest nieszkodliwa. Prawdziwe payloady zawierają liczby zmiennoprzecinkowe, duże liczby całkowite, escape’y Unicode albo dodatkowe białe znaki, a te bajty już zmieniają. Podpisuj surowe body, nie jego ponownie zserializowaną kopię; tabela w sekcji 3 pokazuje, które postacie się psują.

Czy przy porównywaniu podpisów uwzględniać prefiks sha256=?

Trzeba go obciąć albo dodać do własnej wartości, tak żeby oba ciągi zgadzały się dokładnie. Policzony digest hex ma 64 znaki, a wartość nagłówka z prefiksem 71. Część funkcji porównujących zwraca false przy niezgodności długości, a timingSafeEqual w Node rzuca wyjątek, zamiast zwrócić false.

Czy da się zweryfikować podpis po tym, jak framework sparsował JSON?

Nie w sposób wiarygodny. Ponowna serializacja odtwarza oryginalne bajty tylko dla payloadów bez liczb zmiennoprzecinkowych, bez liczb całkowitych powyżej 2^53, bez escape’ów Unicode i bez dodatkowych białych znaków. W chwili, w której pojawia się choć jedno z tego, digest się zmienia, więc weryfikacja przechodzi w testach i zawodzi na części zdarzeń produkcyjnych.

Dlaczego Stripe i GitHub dają różne podpisy dla tego samego payloadu?

Bo liczą hash z różnych ciągów. GitHub podpisuje samo surowe body. Stripe podpisuje timestamp, dosłowną ., a potem body, więc jeden payload dostarczony w dwóch różnych momentach daje dwa różne digesty. Slack dokleja z przodu v0: i własny timestamp. Ten sam algorytm, inne wejście.

Jak długa powinna być tolerancja timestampu?

Pięć minut stosują Stripe i Slack, a skopiowanie tej wartości jest rozsądnym domyślnym wyborem. Krótsze okna odrzucają uprawnione dostarczenia, gdy tylko zegar serwera odjedzie. Dłuższe okna poszerzają okres, w którym przechwycone żądanie da się odtworzyć. Przed poluzowaniem tolerancji zsynchronizuj zegary przez NTP.

Czy timingSafeEqual zwraca false, gdy długości się różnią?

Nie. Node rzuca RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length. Jeśli nikt tego wyjątku nie przechwyci, dostaje się 500 zamiast 401, co wysyła na diagnozę handlera, a nie linii nad porównaniem. Najpierw porównaj długości i sam zwróć false.

Dostawca zrotował sekret. Dlaczego część webhooków nadal zawodzi?

Okna rotacji nakładają się. Stripe utrzymuje stary sekret jako ważny do 24 godzin i wysyła po jednym podpisie v1 na każdy aktywny sekret, więc kod czytający tylko pierwsze v1 zawodzi na około połowie zdarzeń. Shopify może potrzebować nawet godziny, żeby zacząć używać nowego sekretu.

Podsumowanie

Weryfikacja jest porównaniem bajtów, więc webhook signature verification failed zawsze sprowadza się do niezgody co do bajtów, a nie do czegokolwiek kryptograficznego. Trzy wymiary trzeba przy tym rozdzielić. Przechwyć surowe body, zanim dotknie go jakikolwiek parser, i nigdy nie licz hasha z ponownie zserializowanego obiektu: zgadza się on dość często, żeby przejść testy, i zbyt rzadko, żeby działać. Odczytaj sekret tak, jak odczytuje go dostawca, bo ten sam ciąg jako tekst, jako hex i jako base64 daje trzy różne klucze. Na koniec sprawdź kodowanie, w którym doszło do porównania: hex ma 64 znaki, base64 44, a oba opisują te same 32 bajty.

Potem, z grubsza w tej kolejności prawdopodobieństwa: prefiks z timestampem, prefiks wartości, okno tolerancji, nakładanie się rotacji i warstwa transportowa. Niezależnie od tego, jak wygląda samo porównanie, najpierw zabezpiecz długość, a potem oddaj oba ciągi funkcji o stałym czasie ze swojego środowiska uruchomieniowego.

Kiedy potrzebna jest wartość, której można zaufać jako punktowi odniesienia, policz ją poza aplikacją: wklej body i sekret do generatora HMAC i pozwól narzędziu wskazać, która strona jest zła.

Tagi: webhook hmac api-security debugging authentication

Powiązane artykuły

Zobacz wszystkie artykuły