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:
- 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.
- Jak zakodowany jest skrót. Hex albo base64, z prefiksem schematu takim jak
v1=albosha256=. - 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ć.
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łówek | Wartość | Co z tym zrobić |
|---|---|---|
X-Elido-Signature | v1= + 64 małe znaki hex | Porównaj z obliczoną przez siebie wartością |
X-Webhook-Signature | Ta sama wartość co powyżej | Starszy alias; czytaj jeden z nich, nie oba |
X-Webhook-Timestamp | Sekundy unixowe, np. 1789000000 | Część podpisanej wiadomości; sprawdź wiek |
X-Elido-Signature-Previous | v1= + hex, podpisany starym sekretem | Obecny tylko w oknie karencji rotacji |
X-Webhook-Event | Nazwa zdarzenia, np. link.created | Skieruj zdarzenie do obsługi (po weryfikacji) |
X-Webhook-Delivery | Numeryczny ID dostarczenia, stały w ponowieniach | Klucz 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 sekretemX-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.
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:
- Ponownie zserializowany JSON.
JSON.stringify(req.body)albojson.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. - 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 zechodo pliku sekretów robi to samo, dlatego przykład w Pythonie wywołuje.strip(). - 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ą. - Zły znacznik czasu. Użyj ciągu z nagłówka
X-Webhook-Timestamp, nie polatimestampz 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
- Webhooki dla zdarzeń linków - typy zdarzeń, koperta danych i polityka ponowień.
- Webhooki kontra polling w śledzeniu kliknięć - kiedy push wygrywa z pull, a kiedy nie.
- Samodzielnie hostowana automatyzacja linków z n8n - reverse proxy, tryb kolejki i kontrole podpisu w jednym stosie.
- API skracacza URL: limity szybkości, ponowienia, idempotencja - wzorce deduplikacji z drugiego kierunku.
- Checklista bezpieczeństwa skracacza URL - dziewięć kontroli do sprawdzenia u dowolnego dostawcy.
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