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:
- 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.
- Jak zakodowany jest skrót. Hex lub base64, z przedrostkiem schematu, takim jak
v1=lubsha256=. - 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.
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łówek | Wartość | Co z nim zrobić |
|---|---|---|
X-Elido-Signature | v1= + 64 małe znaki hex | Porównaj z obliczoną wartością |
X-Webhook-Signature | Ta sama wartość co wyżej | Starszy alias; czytaj któryś, nie oba |
X-Webhook-Timestamp | Sekundy uniksowe, np. 1789000000 | Część podpisanej wiadomości; sprawdź jego wiek |
X-Elido-Signature-Previous | v1= + hex, podpisany starym sekretem | Obecny tylko w oknie karencji rotacji |
X-Webhook-Event | Nazwa zdarzenia, np. link.created | Kieruj zdarzenie (po weryfikacji) |
X-Webhook-Delivery | Numeryczny ID dostawy, stały przy ponownych próbach | Klucz 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ściv1=w jednym nagłówkuStripe-Signature, podpisujetimestamp.bodyprzez 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 wX-Hub-Signature-256i 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-timestampiwebhook-signature, podpisujeid.timestamp.body, koduje w base64 z przedrostkiemv1,i wartość tolerancji zostawia Tobie (specyfikacja).X-Webhook-Deliveryw 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 sekretemX-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.
Dlaczego weryfikacja podpisu webhooka zawodzi
Gdy pomagam to debugować, prawie za każdym razem jest ta sama krótka lista. Przejdź ją po kolei:
- Ponownie zserializowany JSON.
JSON.stringify(req.body)lubjson.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. - 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 zechodo pliku sekretów, dlatego przykład w Pythonie wywołuje.strip(). - 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ą. - Zły znacznik czasu. Użyj ciągu z nagłówka
X-Webhook-Timestamp, a nie polatimestampz 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
- Webhooki dla zdarzeń linków - typy zdarzeń, koperta payloadu i polityka ponownych prób.
- Webhooki vs polling przy śledzeniu kliknięć - kiedy push bije pull, a kiedy nie.
- Self-hostowana automatyzacja linków z n8n - reverse proxy, tryb kolejki i sprawdzanie podpisów w jednym stosie.
- API skracacza URL: limity zapytań, ponowne próby, idempotentność - wzorce deduplikacji z drugiej strony.
- Lista kontrolna bezpieczeństwa skracacza URL - dziewięć kontroli do sprawdzenia u każdego dostawcy.
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.