10 min czytaniaInżynieria

Weryfikacja podpisu webhooka

Jak zweryfikować podpis webhooka HMAC-SHA256: surowe ciało, porównanie w stałym czasie, okno replay i rotacja sekretu, z kodem w Node, Python.

Marius Voß
DevRel · edge infra

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

To cały algorytm. Błędy biorą się ze szczegółów wokół niego: parser ciała, który zadziałał za wcześnie, sekret, który został zdekodowany, skrót hex porównany z base64. Ten wpis omawia weryfikację podpisów HMAC webhooków ogólnie, a potem działający kod Elido dla Node, Python, Go i węzła Code w n8n, plus okno replay i rotację sekretu, które większość quickstartów pomija.

Jeśli nie skonfigurowałeś jeszcze endpointu, zacznij od webhooków dla zdarzeń linków, który wymienia każdy typ zdarzenia i kopertę payloadu. Ta strona zaczyna się, gdy podpisane żądanie trafia na Twój serwer.

Jak działają podpisy HMAC webhooków

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

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

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

Umieszczenie znacznika czasu wewnątrz podpisanej wiadomości ma znaczenie. Zapobiega temu, by atakujący sparował stare, poprawnie podpisane ciało ze świeżym nagłówkiem znacznika czasu, co w ogóle umożliwia egzekwowanie okna replay.

Jak zweryfikować podpis webhooka: Elido łączy uniksowy znacznik czasu, kropkę i surowe ciało, hashuje je HMAC-SHA256 pod sekretem whsec_ i wysyła hex v1= w X-Elido-Signature; odbiorca przelicza tę samą wartość z surowych bajtów i nagłówka znacznika czasu, a potem porównuje w stałym czasie Formularz dodawania endpointu webhooka w Elido z URL endpointu, wyborem zdarzeń i sekretem podpisu, którego odbiorcy używają do weryfikacji HMAC

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

Każda dostawa do endpointu event lub siem to POST z Content-Type: application/json i ciałem w kształcie {"type", "workspace_id", "data", "timestamp"}. Endpointy w formie czatu (Discord, Telegram, Sentry) uwierzytelniają się przez swój URL i nie niosą nagłówków HMAC, więc wszystko poniżej dotyczy tylko dwóch pierwszych rodzajów.

NagłówekWartośćCo z nim zrobić
X-Elido-Signaturev1= + 64 małe znaki hexPorównaj z obliczoną wartością
X-Webhook-SignatureTa sama wartość co wyżejStarszy alias; czytaj któryś, nie oba
X-Webhook-TimestampSekundy uniksowe, np. 1789000000Część podpisanej wiadomości; sprawdź jego wiek
X-Elido-Signature-Previousv1= + hex, podpisany starym sekretemObecny tylko w oknie karencji rotacji
X-Webhook-EventNazwa zdarzenia, np. link.createdKieruj zdarzenie (po weryfikacji)
X-Webhook-DeliveryNumeryczny ID dostawy, stały przy ponownych próbachKlucz deduplikacji do idempotentnego przetwarzania

Sekret dostajesz przy tworzeniu: whsec_ i 64 znaki szesnastkowe. Jest zwracany raz w odpowiedzi na utworzenie i nigdy więcej, więc od razu trafia do Twojego magazynu sekretów.

Oto wektor testowy, na którym możesz sprawdzić swój kod: dla sekretu whsec_test_only_do_not_use, znacznika czasu 1789000000 i treści {"type":"link.created","workspace_id":42} poprawna wartość nagłówka to:

v1=b9369aa411a8b7ce705bcd5bba112dea9d72d2e787aa88959ff62f33942d1a15

Możesz ją odtworzyć z powłoki, co jest moim pierwszym ruchem, ilekroć odbiorca 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: pole timestamp w treści to czas, w którym zdarzenie nastąpiło, a podpisany znacznik czasu to ten z nagłówka X-Webhook-Timestamp, ustawiany w chwili wysłania żądania. Nie myl ich.

Walidacja podpisu webhooka w Node

W Express poprawka większości błędów to jedna linijka: zamontuj express.raw() na trasie webhooka, żeby req.body był Bufferem dokładnych odebranych bajtów. Zarejestruj tę trasę przed jakimkolwiek globalnym app.use(express.json()), bo gdy parser JSON zużyje strumień, parser raw nie ma już 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 to nie ozdoba. timingSafeEqual rzuca wyjątek dla buforów o różnej długości, zamiast zwrócić false. Jeśli używasz SDK TypeScript z quickstartu API i SDK, webhooks.verify() w @elido/sdk robi ten sam HMAC i porównanie bezpieczne czasowo. Przekaż jednak jawnie { maxSkewSec: 300 }: bez tej opcji w ogóle nie sprawdza wieku znacznika czasu.

Weryfikacja webhooka HMAC SHA256 w Python i Go

Biblioteka standardowa Pythona to pokrywa. We 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 przeczytaj ciało raz, ogranicz jego rozmiar i użyj hmac.Equal z crypto/hmac, które porównuje w stałym czasie. Wiadomość budowałem z ciągu nagłówka dokładnie takiego, jaki przyszedł, a nie z przeformatowanej sparsowanej liczby.

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 sprawdzenia. Elido liczy każdy status inny niż 2xx jako nieudaną próbę, więc jeśli Twój weryfikator jest błędny, prawdziwe dostawy narastają jako 401 w logu dostaw endpointu. To pierwsze miejsce do sprawdzenia po wdrożeniu.

Chcesz zobaczyć to na żywym ruchu? Utwórz endpoint w przestrzeni roboczej ze strony funkcji webhooków i skieruj go na lokalny tunel; log dostaw pokazuje kod statusu zwrócony przez Twój weryfikator dla każdej próby.

Weryfikacja podpisu webhooka w węźle Code w n8n

n8n może wykonać to samo sprawdzenie 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 węzeł Code zaraz za nim. Wbudowany moduł crypto jest dozwolony w węźle Code n8n, więc to 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 dalej nie działa na sfałszowanym zdarzeniu. Jedna pułapka przy self-hostingu: jeśli Twoja instancja ustawia N8N_BLOCK_ENV_ACCESS_IN_NODE=true, węzeł Code nie może czytać $env, sekret wraca pusty i każda dostawa nie przechodzi sprawdzenia. Przewodnik po self-hostowanym n8n omawia stronę reverse proxy, a wpis o węźle skracacza URL w n8n pokazuje, co zbudować, gdy zdarzenia już docierają.

Czym Elido różni się od Stripe, GitHub i Standard Webhooks

Jeśli pisałeś weryfikator dla innego dostawcy, nie kopiuj go na ślepo. Sprawdzone w dokumentacji każdego dostawcy 2026-10-25:

  • Stripe umieszcza t= i jedną lub więcej wartości v1= w jednym nagłówku Stripe-Signature, podpisuje timestamp.body przez HMAC-SHA256 w hex, a jego biblioteki domyślnie przyjmują tolerancję pięciu minut (dokumentacja Stripe). Elido wydziela znacznik czasu do osobnego nagłówka.
  • GitHub podpisuje samo surowe ciało, wysyła sha256= plus hex w X-Hub-Signature-256 i każe użyć porównania w stałym czasie (dokumentacja GitHub). Nie ma znacznika czasu do sprawdzenia.
  • Standard Webhooks używa webhook-id, webhook-timestamp i webhook-signature, podpisuje id.timestamp.body, koduje w base64 z przedrostkiem v1, i wartość tolerancji zostawia Tobie (specyfikacja). X-Webhook-Delivery w Elido pełni rolę ID, ale nie jest częścią podpisanej wiadomości.

Własny schemat Elido to ten z signing.Sign w api-core: wiadomość to uniksowy znacznik czasu, kropka i ciało, kluczem jest ciąg sekretu tak, jak jest przechowywany, a wynikiem v1= plus małe litery hex. Elido nie ma zdarzenia link.clicked, więc nie oczekuj dostaw na poziomie kliknięć w tym kanale.

Okna replay i rotacja sekretu

Prawidłowy podpis dowodzi, kto wysłał żądanie, a nie kiedy. Ktoś, kto przechwyci jedną podpisaną dostawę, z linii logu lub źle skonfigurowanego proxy, mógłby wysłać ją ponownie tydzień później, a HMAC nadal by pasował. Sprawdzenie znacznika czasu zamyka tę lukę i to Twoje zadanie: worker dostaw Elido podpisuje znacznik czasu, ale nie egzekwuje żadnego okna po Twojej stronie. Używam 300 sekund. Utrzymuj zegar odbiorcy zsynchronizowany przez NTP, bo serwer, który dryfuje o kilka minut, zacznie odrzucać uczciwy ruch.

Ponowne próby nie wchodzą w konflikt z oknem. Każda próba dostaje świeży X-Webhook-Timestamp i nowy podpis, a X-Webhook-Delivery pozostaje ten sam. Ten podział daje obie obrony: znacznik czasu ogranicza, jak długo przechwycone żądanie pozostaje użyteczne, a unikalny indeks na ID dostawy zapobiega dwukrotnemu przetworzeniu prawidłowej ponownej próby. Wpis o limitach zapytań i idempotentności omawia ten sam wzorzec po stronie przychodzącego API.

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

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

Dlatego każdy powyższy fragment sprawdza oba nagłówki względem jednego sekretu, który trzyma. Odbiorca nadal używający starego sekretu pasuje do drugiego nagłówka; po wdrożeniu nowego pasuje do pierwszego. Nic nie zawodzi po drodze. Traktuj jednak nagłówek poprzedniego klucza jako most, a nie gwarancję, i wdróż nowy sekret wcześnie w tygodniu.

Oś czasu rotacji sekretu webhooka: przed rotacją wysyłany jest tylko X-Elido-Signature; po rotate-secret, przez siedmiodniowe okno karencji, dostawy niosą X-Elido-Signature z nowym kluczem i X-Elido-Signature-Previous ze starym, więc odbiorca z którymkolwiek sekretem weryfikuje.

Dlaczego weryfikacja podpisu webhooka zawodzi

Gdy pomagam to debugować, prawie za każdym razem jest ta sama krótka lista. Przejdź ją po kolei:

  1. Ponownie zserializowany JSON. JSON.stringify(req.body) lub json.dumps(payload) daje inne bajty niż te, które zahashował nadawca: kolejność kluczy, odstępy, escapowane ukośniki, sekwencje unicode. Hashuj surowe ciało. Jeśli framework już je sparsował, napraw kolejność middleware zamiast próbować odbudować ciąg.
  2. Złe bajty klucza. Elido używa całego ciągu whsec_... jako klucza HMAC. Odcięcie przedrostka, hex-dekodowanie reszty lub dekodowanie base64 (co robią biblioteki Standard Webhooks) daje inny klucz. To samo robi końcowy znak nowej linii z echo do pliku sekretów, dlatego przykład w Pythonie wywołuje .strip().
  3. Niezgodność kodowania. Porównuj v1= plus małe litery hex z nagłówkiem. Skrót base64, hex wielkimi literami czy brak przedrostka nigdy nie pasują.
  4. Zły znacznik czasu. Użyj ciągu z nagłówka X-Webhook-Timestamp, a nie pola timestamp z ciała ani liczby, którą sparsowałeś i przeformatowałeś.

Dwie mniejsze: porównanie przez == działa, ale zdradza czas, więc użyj funkcji stałoczasowej, którą ma Twój język; a proxy, które dekompresuje lub przekodowuje ciała, też zepsuje weryfikację, choć przy zwykłych POST-ach JSON to rzadkie.

Gdy obie strony nadal się nie zgadzają, zaloguj znacznik czasu, długość ciała i kilka pierwszych znaków hex swojego skrótu, a potem uruchom linię openssl z wcześniej na tych samych danych. Ta strona, która zgadza się z openssl, ma rację. Gdzie podpisywanie mieści się wśród innych kontroli wartych sprawdzenia u każdego dostawcy, zobacz listę kontrolną bezpieczeństwa skracacza URL.

Przeczytaj filar: webhooki dla zdarzeń linków.

Powiązane na blogu

Najczęściej zadawane pytania

Jak zweryfikować podpis webhooka?

Przelicz HMAC dokładnie z tego, co podpisał nadawca, używając wspólnego sekretu, i porównaj wynik z nagłówkiem podpisu w stałym czasie. W Elido oznacza to HMAC-SHA256 z wartości X-Webhook-Timestamp, kropki i surowego ciała, zakodowany w hex z przedrostkiem v1=. Odrzuć żądanie, jeśli nic się nie zgadza lub znacznik czasu jest nieaktualny.

Dlaczego weryfikacja podpisu mojego webhooka ciągle zawodzi?

Prawie zawsze dlatego, że zahashowałeś inne bajty niż nadawca. Najpierw zadziałał parser JSON i serializujesz obiekt ponownie, albo zdekodowałeś sekret, albo porównujesz hex z base64. Hashuj surowe bajty żądania, używaj sekretu dokładnie takiego, jaki wydano, i zaloguj obie wartości obok siebie.

Czym jest HMAC w webhooku?

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

Jak zapobiegać atakom replay na webhooki?

Podpisuj znacznik czasu razem z ciałem i odrzucaj każde żądanie, którego znacznik czasu jest starszy niż kilka minut; pięć minut to częste okno. Potem zapisuj identyfikator dostawy i pomijaj identyfikatory, które już przetworzyłeś. Elido podpisuje znacznik czasu, ale sprawdzenie świeżości zostawia Twojemu odbiorcy.

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

Tego, co dokumentuje nadawca, bo dwa kodowania tego samego skrótu nigdy nie są sobie równe. Elido wysyła małe litery hex po przedrostku v1=. Shopify i specyfikacja Standard Webhooks używają base64, a GitHub używa hex po sha256=. Zakoduj swój skrót tak samo, zanim porównasz.

Jak rotować sekret webhooka, nie gubiąc zdarzeń?

Użyj nadawcy, który przez pewien czas podpisuje oboma kluczami. Po rotacji sekretu endpointu Elido każda dostawa niesie X-Elido-Signature z nowym kluczem i X-Elido-Signature-Previous ze starym przez siedem dni. Akceptuj któryś z 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.

Bez rejestracji. Załóż darmowe konto i korzystaj z 50 linków miesięcznie.

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