10 min czytaniaInżynieria

Weryfikacja podpisów webhooków: HMAC-SHA256 w Node, Pythonie i Go

Jak zweryfikować podpis webhooka za pomocą HMAC-SHA256: surowe body, porównanie w stałym czasie, okno ochrony przed powtórzeniem (replay) i rotacja sekretu, z kodem dla Node, Pythona, Go i n8n.

Marius Voß
DevRel · edge infra
Okładka w stylu pikselowym pokazująca, jak weryfikować nagłówki podpisu webhooka: znacznik czasu i surowe body haszowane za pomocą HMAC-SHA256 do wartości szesnastkowej v1=, porównywanej w stałym czasie obok pikselowego klucza

Aby zweryfikować podpis webhooka, ponownie oblicz HMAC dla dokładnie tych bajtów, które podpisał nadawca, używając sekretu, który z nim współdzielisz, i porównaj swoją wartość z nagłówkiem podpisu w stałym czasie. Następnie sprawdź, czy podpisany znacznik czasu jest świeży. W przypadku webhooków Elido oznacza to HMAC-SHA256 z kluczem będącym całym sekretem whsec_..., dla {X-Webhook-Timestamp}.{raw body}, zakodowany szesnastkowo, z prefiksem v1=, dopasowany do X-Elido-Signature.

To cały algorytm. Błędy biorą się ze szczegółów wokół niego: parser body, który uruchomił się zbyt wcześnie, sekret, który został zdekodowany, skrót szesnastkowy porównany z base64. Ten wpis omawia ogólnie weryfikację podpisu webhooka HMAC, a potem pokazuje działający kod Elido dla Node, Pythona, Go i węzła Code w n8n, wraz z oknem ochrony przed powtórzeniem (replay) i rotacją sekretu, które większość szybkich startów pomija.

Jeśli endpoint nie jest jeszcze skonfigurowany, zacznij od webhooków dla zdarzeń linków, gdzie wymieniono każdy typ zdarzenia i kopertę danych. Ta strona zaczyna się w momencie, gdy podpisane żądanie trafia na Twój serwer.

Jak działają podpisy webhooków HMAC

Endpoint webhooka to publiczny URL. Każdy, kto go znajdzie, może wysłać metodą POST body JSON wyglądające jak prawdziwe zdarzenie, więc odbiornik potrzebuje dowodu pochodzenia. HMAC daje go tanio: nadawca i odbiornik współdzielą sekret, nadawca oblicza HMAC-SHA256(secret, message) i umieszcza wynik w nagłówku, a odbiornik wykonuje to samo obliczenie i porównuje. Bez sekretu nikt nie może utworzyć pasującej wartości, a zmiana pojedynczego bajtu wiadomości zmienia cały skrót.

Trzy szczegóły różnią się między dostawcami i każdy z nich psuje weryfikację, jeśli zrobisz go źle:

  1. Co trafia do wiadomości. GitHub podpisuje samo surowe body. Stripe i Elido podpisują znacznik czasu, kropkę i body. Specyfikacja Standard Webhooks podpisuje identyfikator wiadomości, znacznik czasu i body.
  2. Jak zakodowany jest skrót. Hex albo base64, z prefiksem schematu takim jak v1= albo sha256=.
  3. Czym jest klucz. Niektórzy dostawcy dekodują sekret z base64 po jego prefiksie. Elido tego nie robi: kluczem jest pełny ciąg whsec_ jako bajty UTF-8.

Umieszczenie znacznika czasu wewnątrz podpisanej wiadomości ma znaczenie. Uniemożliwia atakującemu połączenie starego, prawidłowo podpisanego body ze świeżym nagłówkiem znacznika czasu, i dopiero dzięki temu okno ochrony przed powtórzeniem da się w ogóle egzekwować.

Jak zweryfikować podpis webhooka: Elido łączy unixowy znacznik czasu, kropkę i surowe body, haszuje je za pomocą HMAC-SHA256 pod sekretem whsec_ i wysyła szesnastkowe v1= w X-Elido-Signature; odbiornik ponownie oblicza tę samą wartość z surowych bajtów i nagłówka znacznika czasu, a następnie porównuje w stałym czasie

Co podpisuje Elido i które nagłówki to przenoszą

Każde dostarczenie do endpointu typu event albo siem to żądanie POST z Content-Type: application/json i body w kształcie {"type", "workspace_id", "data", "timestamp"}. Endpointy typu czatowego (Discord, Telegram, Sentry) uwierzytelniają się przez swój URL i nie przenoszą nagłówków HMAC, więc wszystko poniżej dotyczy tylko pierwszych dwóch typów.

NagłówekWartośćCo z tym zrobić
X-Elido-Signaturev1= + 64 małe znaki hexPorównaj z obliczoną przez siebie wartością
X-Webhook-SignatureTa sama wartość co powyżejStarszy alias; czytaj jeden z nich, nie oba
X-Webhook-TimestampSekundy unixowe, np. 1789000000Część podpisanej wiadomości; sprawdź wiek
X-Elido-Signature-Previousv1= + hex, podpisany starym sekretemObecny tylko w oknie karencji rotacji
X-Webhook-EventNazwa zdarzenia, np. link.createdSkieruj zdarzenie do obsługi (po weryfikacji)
X-Webhook-DeliveryNumeryczny ID dostarczenia, stały w ponowieniachKlucz deduplikacji do idempotentnego przetwarzania

Sekret jest generowany dla Ciebie podczas tworzenia endpointu: whsec_, po którym następują 64 znaki szesnastkowe. Jest zwracany raz w odpowiedzi tworzenia i nigdy później, więc trafia od razu do magazynu sekretów.

Oto wektor testowy, na którym możesz uruchomić swój kod. Z sekretem whsec_test_only_do_not_use, znacznikiem czasu 1789000000 i body {"type":"link.created","workspace_id":42} prawidłowa wartość nagłówka to:

v1=b9369aa411a8b7ce705bcd5bba112dea9d72d2e787aa88959ff62f33942d1a15

Możesz odtworzyć ją z powłoki, co jest moim pierwszym ruchem za każdym razem, gdy odbiornik nie zgadza się z nadawcą:

printf '%s.%s' 1789000000 '{"type":"link.created","workspace_id":42}' \
  | openssl dgst -sha256 -hmac 'whsec_test_only_do_not_use' -r

Jedna pułapka: własne pole timestamp w body oznacza czas wystąpienia zdarzenia. Podpisany znacznik czasu to ten z nagłówka X-Webhook-Timestamp, ustawiany przy wysyłaniu żądania. Nie pomyl ich.

Walidacja podpisu webhooka w Node

W Express poprawka dla większości niepowodzeń to jedna linia: zamontuj express.raw() na trasie webhooka, aby req.body było buforem dokładnych otrzymanych bajtów. Zarejestruj tę trasę przed każdym globalnym app.use(express.json()), bo gdy parser JSON zużyje już strumień, parser surowy nie ma czego czytać.

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.ELIDO_WEBHOOK_SECRET; // the full whsec_... string
const TOLERANCE_SEC = 300;

function matches(expected, got) {
  const a = Buffer.from(expected);
  const b = Buffer.from(got ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}

const app = express();

app.post(
  "/webhooks/elido",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ts = req.get("X-Webhook-Timestamp") ?? "";
    if (
      !/^\d+$/.test(ts) ||
      Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SEC
    ) {
      return res.status(400).send("stale or missing timestamp");
    }
    const expected =
      "v1=" +
      createHmac("sha256", SECRET)
        .update(`${ts}.`)
        .update(req.body)
        .digest("hex");
    const ok = [
      req.get("X-Elido-Signature"),
      req.get("X-Elido-Signature-Previous"),
    ].some((got) => matches(expected, got));
    if (!ok) return res.status(401).send("bad signature");

    const event = JSON.parse(req.body.toString("utf8"));
    // enqueue event, then acknowledge fast
    res.sendStatus(200);
  },
);

Sprawdzenie długości nie jest ozdobą. timingSafeEqual zgłasza wyjątek dla buforów o różnych długościach zamiast zwrócić false. Jeśli używasz SDK TypeScript z szybkiego startu API i SDK, webhooks.verify() w @elido/sdk wykonuje ten sam HMAC i porównanie odporne na pomiar czasu. Przekaż jednak jawnie { maxSkewSec: 300 }: bez tej opcji w ogóle nie sprawdza wieku znacznika czasu.

Weryfikacja webhooka HMAC SHA256 w Pythonie i Go

Standardowa biblioteka Pythona wystarcza. W FastAPI await request.body() zwraca surowe bajty; we Flasku wywołaj request.get_data(), zanim cokolwiek dotknie request.json.

import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, HTTPException, Request

SECRET = os.environ["ELIDO_WEBHOOK_SECRET"].strip().encode()
TOLERANCE = 300
app = FastAPI()


def verify(raw: bytes, ts: str, candidates: list) -> bool:
    if not ts.isdigit() or abs(time.time() - int(ts)) > TOLERANCE:
        return False
    digest = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    expected = "v1=" + digest
    return any(c and hmac.compare_digest(c, expected) for c in candidates)


@app.post("/webhooks/elido")
async def elido_webhook(request: Request):
    raw = await request.body()
    h = request.headers
    sigs = [h.get("x-elido-signature"), h.get("x-elido-signature-previous")]
    if not verify(raw, h.get("x-webhook-timestamp", ""), sigs):
        raise HTTPException(status_code=401, detail="bad signature")
    event = json.loads(raw)
    # enqueue event
    return {"ok": True}

W Go odczytaj body raz, ogranicz jego rozmiar i użyj hmac.Equal z crypto/hmac, które porównuje w stałym czasie. Buduję wiadomość z ciągu nagłówka dokładnie tak, jak został odebrany, zamiast ponownie formatować sparsowaną liczbę całkowitą.

package webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"strconv"
	"time"
)

const toleranceSec = 300

// Verify returns the raw body when the request carries a valid Elido signature.
func Verify(w http.ResponseWriter, r *http.Request, secret []byte) ([]byte, bool) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		return nil, false
	}
	tsHeader := r.Header.Get("X-Webhook-Timestamp")
	ts, err := strconv.ParseInt(tsHeader, 10, 64)
	if err != nil {
		return nil, false
	}
	if age := time.Now().Unix() - ts; age > toleranceSec || age < -toleranceSec {
		return nil, false
	}

	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(tsHeader + "."))
	mac.Write(body)
	expected := []byte("v1=" + hex.EncodeToString(mac.Sum(nil)))

	for _, name := range []string{"X-Elido-Signature", "X-Elido-Signature-Previous"} {
		if got := r.Header.Get(name); got != "" && hmac.Equal([]byte(got), expected) {
			return body, true
		}
	}
	return nil, false
}

Wszystkie trzy wersje odpowiadają 401 przy złym podpisie i parsują JSON dopiero po przejściu kontroli. Elido liczy każdy wynik spoza 2xx jako nieudaną próbę, więc jeśli weryfikator jest błędny, prawdziwe dostarczenia gromadzą się jako 401 w dzienniku dostarczeń endpointu. To pierwsze miejsce, w które warto zajrzeć po wdrożeniu.

Chcesz zobaczyć to na ruchu na żywo? Utwórz endpoint w przestrzeni roboczej ze strony funkcji webhooków i skieruj go do lokalnego tunelu; dziennik dostarczeń pokazuje kod statusu zwrócony przez Twój weryfikator przy każdej próbie.

Weryfikacja podpisu webhooka w węźle Code n8n

n8n potrafi wykonać tę samą kontrolę bez dodatkowej usługi. Włącz opcję Raw Body w węźle Webhook, która zapisuje nietknięte żądanie jako dane binarne, a potem dodaj zaraz po nim węzeł Code. Wbudowany moduł crypto jest dozwolony w węźle Code n8n, więc ten kod działa bez zmian:

const crypto = require("crypto");
const h = $input.first().json.headers;
const raw = await this.helpers.getBinaryDataBuffer(0, "data");

const ts = h["x-webhook-timestamp"] ?? "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
  throw new Error("stale delivery");
}
const expected = Buffer.from(
  "v1=" +
    crypto
      .createHmac("sha256", $env.ELIDO_WEBHOOK_SECRET)
      .update(`${ts}.`)
      .update(raw)
      .digest("hex"),
);
const ok = ["x-elido-signature", "x-elido-signature-previous"].some((name) => {
  const got = Buffer.from(h[name] ?? "");
  return (
    got.length === expected.length && crypto.timingSafeEqual(got, expected)
  );
});
if (!ok) throw new Error("bad signature");
return [{ json: JSON.parse(raw.toString("utf8")) }];

Rzucony błąd zatrzymuje wykonanie, więc nic niżej nie uruchomi się dla sfałszowanego zdarzenia. Jeden haczyk przy samodzielnym hostowaniu: jeśli instancja ustawia N8N_BLOCK_ENV_ACCESS_IN_NODE=true, węzeł Code nie może odczytać $env, sekret wraca pusty i każde dostarczenie nie przechodzi kontroli. Przewodnik po samodzielnie hostowanym n8n omawia stronę reverse proxy, a wpis o skracaczu URL w n8n pokazuje, co zbudować, gdy zdarzenia już przychodzą.

Okno ochrony przed powtórzeniem i rotacja sekretu

Prawidłowy podpis dowodzi, kto wysłał żądanie, ale nie kiedy. Ktoś, kto przechwyci jedno podpisane dostarczenie, z linii logu albo źle skonfigurowanego proxy, mógłby wysłać je ponownie tydzień później, a HMAC nadal by pasował. Kontrola znacznika czasu zamyka tę lukę i to jest Twoje zadanie: worker dostarczeń Elido podpisuje znacznik czasu, ale nie wymusza żadnego okna po Twojej stronie. Używam 300 sekund. Utrzymuj zegar odbiornika zsynchronizowany z NTP, bo serwer, którego zegar rozjedzie się o kilka minut, zacznie odrzucać prawidłowy ruch.

Ponowienia nie kolidują z tym oknem. Każda próba dostaje świeży X-Webhook-Timestamp i nowy podpis, a X-Webhook-Delivery pozostaje taki sam. Ten podział daje obie obrony: znacznik czasu ogranicza, jak długo przechwycone żądanie pozostaje używalne, a unikatowy indeks na ID dostarczenia zatrzymuje dwukrotne przetworzenie prawidłowego ponowienia. Wpis o limitach szybkości i idempotencji omawia ten sam wzorzec po stronie przychodzącego API.

Rotacja działa przez POST /v1/workspaces/{workspace_id}/webhooks/{id}/rotate-secret albo przycisk Rotate na stronie endpointu. Odpowiedź zawiera nowy sekret raz, a także grace_window_days (7) i previous_expires_at. Przez te siedem dni każde dostarczenie ma dwa podpisy:

  • X-Elido-Signature, utworzony nowym sekretem
  • X-Elido-Signature-Previous, utworzony starym sekretem

Dlatego każdy powyższy przykład sprawdza oba nagłówki względem jednego sekretu, który posiada. Odbiornik nadal działający na starym sekrecie dopasowuje drugi nagłówek; po wdrożeniu nowego dopasowuje pierwszy. Po drodze nic się nie psuje. Traktuj jednak nagłówek poprzedniego klucza jako most, a nie gwarancję, i wdróż nowy sekret na początku tego tygodnia.

Oś czasu rotacji sekretu webhooka: przed rotacją wysyłany jest tylko X-Elido-Signature; po rotate-secret, przez siedmiodniowe okno karencji, dostarczenia niosą X-Elido-Signature z nowym kluczem oraz X-Elido-Signature-Previous ze starym kluczem, więc odbiornik mający dowolny sekret przechodzi weryfikację; po zakończeniu okna podpisuje tylko nowy klucz

Dlaczego weryfikacja podpisu webhooka się nie udaje

Gdy pomagam komuś diagnozować ten problem, prawie za każdym razem pojawia się ta sama krótka lista. Przejdź przez nią po kolei:

  1. Ponownie zserializowany JSON. JSON.stringify(req.body) albo json.dumps(payload) produkuje inne bajty niż te, które haszował nadawca: kolejność kluczy, odstępy, escapowane ukośniki, escapowane znaki unicode. Haszuj surowe body. Jeśli framework już je sparsował, popraw kolejność middleware zamiast próbować odbudować ciąg.
  2. Złe bajty klucza. Elido używa całego ciągu whsec_... jako klucza HMAC. Usunięcie prefiksu, zdekodowanie reszty z hex albo zdekodowanie jej z base64 (co robią biblioteki Standard Webhooks) daje inny klucz. Końcowy znak nowej linii z echo do pliku sekretów robi to samo, dlatego przykład w Pythonie wywołuje .strip().
  3. Niezgodność kodowania. Porównuj v1= plus małe litery w hex z nagłówkiem. Skrót base64, zapis hex wielkimi literami albo brakujący prefiks nigdy nie pasują.
  4. Zły znacznik czasu. Użyj ciągu z nagłówka X-Webhook-Timestamp, nie pola timestamp z body i nie liczby, którą sparsowano i ponownie sformatowano.

Dwie mniejsze rzeczy: porównywanie przez == działa, ale ujawnia informacje przez czas wykonania, więc użyj funkcji stałoczasowej dostarczanej przez Twój język; a proxy, które dekompresuje albo ponownie koduje body, też zepsuje weryfikację, choć przy zwykłych POST-ach JSON to rzadkie.

Gdy obie strony nadal się nie zgadzają, zaloguj znacznik czasu, długość body i pierwsze kilka znaków hex swojego skrótu, a potem uruchom wcześniejszą linię openssl na tych samych danych wejściowych. Strona, która zgadza się z openssl, jest poprawna. O tym, gdzie podpisywanie mieści się wśród innych zabezpieczeń wartych sprawdzenia u dowolnego dostawcy, przeczytasz w checkliście bezpieczeństwa skracacza URL.

Przeczytaj główny poradnik: webhooki dla zdarzeń linków.

Powiązane na blogu

Najczęściej zadawane pytania

Jak zweryfikować podpis webhooka?

Ponownie oblicz HMAC dokładnie dla tego, co podpisał nadawca, używając współdzielonego sekretu, i porównaj wynik z nagłówkiem podpisu w stałym czasie. W przypadku Elido oznacza to HMAC-SHA256 dla wartości X-Webhook-Timestamp, kropki i surowego body, zakodowany szesnastkowo z prefiksem v1=. Odrzuć żądanie, jeśli nic nie pasuje albo znacznik czasu jest nieaktualny.

Dlaczego weryfikacja podpisu webhooka ciągle się nie udaje?

Prawie zawsze dlatego, że haszujesz inne bajty niż nadawca. Parser JSON body uruchomił się wcześniej i ponownie zserializowano obiekt, albo zdekodowano sekret, albo porównujesz zapis szesnastkowy z base64. Haszuj surowe bajty żądania, używaj ciągu sekretu dokładnie w takiej postaci, w jakiej został wydany, i loguj obie wartości obok siebie.

Czym jest HMAC w webhooku?

HMAC to hasz z kluczem: nadawca miesza sekret, który z Tobą współdzieli, z haszem SHA-256 wiadomości. Tylko ktoś, kto ma sekret, może utworzyć pasującą wartość, więc prawidłowy podpis dowodzi, że żądanie przyszło od nadawcy i że body nie zostało zmienione po drodze.

Jak zapobiegać atakom powtórzeniowym (replay) na webhooki?

Podpisuj znacznik czasu razem z body i odrzucaj każde żądanie, którego znacznik czasu jest starszy niż kilka minut; pięć minut to częste okno. Następnie przechowuj identyfikator dostarczenia i pomijaj identyfikatory, które zostały już przetworzone. Elido podpisuje znacznik czasu, ale pozostawia sprawdzanie świeżości Twojemu odbiornikowi.

Czy do podpisu webhooka HMAC-SHA256 używać hex czy base64?

Tego, co dokumentuje nadawca, bo dwa różne kodowania tego samego skrótu nigdy nie będą sobie równe. Elido wysyła małe litery w zapisie szesnastkowym po prefiksie v1=. Shopify i specyfikacja Standard Webhooks używają base64, a GitHub używa zapisu szesnastkowego po sha256=. Przed porównaniem zakoduj swój skrót w ten sam sposób.

Jak rotować sekret webhooka bez gubienia zdarzeń?

Użyj nadawcy, który przez pewien czas podpisuje oboma kluczami. Po rotacji sekretu endpointu Elido każde dostarczenie przez siedem dni zawiera X-Elido-Signature z nowym kluczem oraz X-Elido-Signature-Previous ze starym. Akceptuj dowolny z tych nagłówków, wdróż nowy sekret, a stary wygaśnie sam.

Wypróbuj Elido

Wklej URL, otrzymaj krótki link

Bez rejestracji. Link działa 30 dni. Zarejestruj się, aby zachować go na zawsze.

Za darmo, bez rejestracji · 2 dziennie

Wypróbuj Elido

Skracarka URL hostowana w UE: własne domeny, głęboka analityka i otwarte API. Darmowy plan - bez karty kredytowej.

Tagi
verify webhook signature
webhook signature verification
hmac webhook
webhook hmac sha256
webhook replay attack
webhook secret rotation

Czytaj dalej