Um eine Webhook-Signatur zu prüfen, berechnen Sie einen HMAC über genau die Bytes, die der Absender signiert hat, mit dem Secret, das Sie mit ihm teilen, und vergleichen Ihren Wert in konstanter Zeit mit dem Signatur-Header. Prüfen Sie dann, dass der signierte Zeitstempel aktuell ist. Bei Elido-Webhooks bedeutet das HMAC-SHA256 mit Ihrem gesamten whsec_...-Secret als Schlüssel, über {X-Webhook-Timestamp}.{raw body}, hexkodiert, mit dem Präfix v1=, und abgeglichen mit X-Elido-Signature.
Das ist der ganze Algorithmus. Die Fehler kommen aus den Details drumherum: ein Body-Parser, der zu früh lief, ein Secret, das dekodiert wurde, ein Hex-Digest, der mit einem Base64-Digest verglichen wird. Dieser Beitrag behandelt die HMAC-Prüfung von Webhook-Signaturen allgemein, dann funktionierenden Elido-Code für Node, Python, Go und einen n8n-Code-Node, dazu das Replay-Fenster und die Secret-Rotation, die die meisten Quickstarts auslassen.
Wenn Sie noch keinen Endpunkt eingerichtet haben, beginnen Sie mit Webhooks für Link-Ereignisse, das alle Ereignistypen und die Payload-Hülle auflistet. Diese Seite setzt ein, sobald eine signierte Anfrage auf Ihrem Server landet.
So funktionieren HMAC-Webhook-Signaturen
Ein Webhook-Endpunkt ist eine öffentliche URL. Jeder, der ihn findet, kann einen JSON-Körper per POST senden, der wie ein echtes Ereignis aussieht, der Empfänger braucht also einen Herkunftsnachweis. HMAC liefert ihn günstig: Absender und Empfänger teilen ein Secret, der Absender berechnet HMAC-SHA256(secret, message) und legt das Ergebnis in einen Header, und der Empfänger führt dieselbe Berechnung aus und vergleicht. Ohne das Secret kann niemand einen passenden Wert erzeugen, und die Änderung eines einzigen Bytes der Nachricht ändert den ganzen Digest.
Drei Details unterscheiden sich zwischen Anbietern, und jedes bricht die Prüfung, wenn Sie es falsch machen:
- Was in die Nachricht eingeht. GitHub signiert nur den Rohkörper. Stripe und Elido signieren einen Zeitstempel, einen Punkt und den Körper. Die Standard-Webhooks-Spezifikation signiert eine Nachrichten-ID, den Zeitstempel und den Körper.
- Wie der Digest kodiert ist. Hex oder Base64, mit einem Schema-Präfix wie
v1=odersha256=. - Was der Schlüssel ist. Manche Anbieter dekodieren das Secret nach seinem Präfix aus Base64. Elido nicht: Der Schlüssel ist der vollständige
whsec_-String als UTF-8-Bytes.
Den Zeitstempel in die signierte Nachricht aufzunehmen ist wichtig. Es verhindert, dass ein Angreifer einen alten, gültig signierten Körper mit einem frischen Zeitstempel-Header kombiniert, und erst das macht ein Replay-Fenster überhaupt durchsetzbar.
Was Elido signiert und welche Header es tragen
Jede Zustellung an einen Endpunkt vom Typ event oder siem ist ein POST mit Content-Type: application/json und einem Körper der Form {"type", "workspace_id", "data", "timestamp"}. Chat-artige Endpunkttypen (Discord, Telegram, Sentry) authentifizieren sich über ihre URL und tragen keine HMAC-Header, alles Folgende gilt also nur für die ersten beiden Typen.
| Header | Wert | Was Sie damit tun |
|---|---|---|
X-Elido-Signature | v1= + 64 Hex-Zeichen in Kleinbuchstaben | Mit Ihrem berechneten Wert vergleichen |
X-Webhook-Signature | Derselbe Wert wie oben | Älterer Alias; einen von beiden lesen, nicht beide |
X-Webhook-Timestamp | Unix-Sekunden, z. B. 1789000000 | Teil der signierten Nachricht; Alter prüfen |
X-Elido-Signature-Previous | v1= + Hex, mit dem alten Secret signiert | Nur während eines Rotations-Übergangsfensters |
X-Webhook-Event | Ereignisname, z. B. link.created | Das Ereignis weiterleiten (nach der Prüfung) |
X-Webhook-Delivery | Numerische Delivery-ID, über Retries hinweg stabil | Dedupe-Schlüssel für idempotente Verarbeitung |
Das Secret erhalten Sie beim Anlegen: whsec_ gefolgt von 64 Hex-Zeichen. Es wird einmal in der Antwort auf das Anlegen zurückgegeben und nie wieder, gehört also direkt in Ihren Secret-Speicher.
Hier ist ein Testvektor, gegen den Sie Ihren Code laufen lassen können: Mit dem Secret whsec_test_only_do_not_use, dem Zeitstempel 1789000000 und dem Körper {"type":"link.created","workspace_id":42} lautet der korrekte Header-Wert:
v1=b9369aa411a8b7ce705bcd5bba112dea9d72d2e787aa88959ff62f33942d1a15
Sie können ihn in einer Shell reproduzieren, was mein erster Schritt ist, wann immer ein Empfänger dem Absender widerspricht:
printf '%s.%s' 1789000000 '{"type":"link.created","workspace_id":42}' \
| openssl dgst -sha256 -hmac 'whsec_test_only_do_not_use' -r
Eine Falle dabei: Das eigene Feld timestamp im Körper ist der Zeitpunkt, an dem das Ereignis stattfand, während der signierte Zeitstempel ist der im Header X-Webhook-Timestamp, gesetzt beim Senden der Anfrage. Verwechseln Sie sie nicht.
Webhook-Signatur in Node validieren
In Express ist die Lösung für die meisten Fehler eine einzige Zeile: Hängen Sie express.raw() an die Webhook-Route, damit req.body ein Buffer der exakt empfangenen Bytes ist. Registrieren Sie diese Route vor jedem globalen app.use(express.json()), denn sobald der JSON-Parser den Stream verbraucht hat, hat der Raw-Parser nichts mehr zu lesen.
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);
},
);
Die Längenprüfung ist keine Dekoration. timingSafeEqual wirft bei Buffern unterschiedlicher Länge eine Ausnahme, statt false zurückzugeben. Wenn Sie das TypeScript-SDK aus dem Quickstart zu API und SDKs nutzen, führt webhooks.verify() in @elido/sdk denselben HMAC und denselben zeitsicheren Vergleich aus. Übergeben Sie aber ausdrücklich { maxSkewSec: 300 }: Ohne diese Option prüft es das Alter des Zeitstempels überhaupt nicht.
Webhook-HMAC-SHA256-Prüfung in Python und Go
Die Standardbibliothek von Python deckt das ab. Mit FastAPI liefert await request.body() die Roh-Bytes; rufen Sie in Flask request.get_data() auf, bevor irgendetwas request.json anfasst.
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}
In Go lesen Sie den Körper einmal, begrenzen seine Größe und nutzen hmac.Equal aus crypto/hmac, das in konstanter Zeit vergleicht. Ich baue die Nachricht aus dem Header-String exakt wie empfangen, statt die geparste Ganzzahl neu zu formatieren.
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
}
Alle drei Versionen antworten bei einer falschen Signatur mit 401 und parsen JSON erst, nachdem die Prüfung bestanden ist. Elido zählt jeden Nicht-2xx-Status als fehlgeschlagenen Versuch, wenn Ihr Prüfer also falsch ist, häufen sich echte Zustellungen als 401 im Zustellungs-Log des Endpunkts. Das ist nach einem Deployment die erste Stelle zum Nachsehen.
Möchten Sie das gegen Live-Traffic sehen? Legen Sie in einem Workspace über die Webhooks-Funktionsseite einen Endpunkt an und richten Sie ihn auf einen lokalen Tunnel; das Zustellungs-Log zeigt den Statuscode, den Ihr Prüfer bei jedem Versuch zurückgab.
Die Webhook-Signatur in einem n8n-Code-Node prüfen
n8n kann dieselbe Prüfung ohne zusätzlichen Dienst durchführen. Schalten Sie im Webhook-Node die Option Raw Body ein, die die unveränderte Anfrage als Binärdaten speichert, und fügen Sie direkt danach einen Code-Node hinzu. Das eingebaute Modul crypto ist im n8n-Code-Node erlaubt, das läuft also unverändert:
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")) }];
Ein geworfener Fehler stoppt die Ausführung, sodass bei einem gefälschten Ereignis nichts weiter unten läuft. Ein Haken beim Self-Hosting: Wenn Ihre Instanz N8N_BLOCK_ENV_ACCESS_IN_NODE=true setzt, kann der Code-Node $env nicht lesen, das Secret kommt leer zurück, und jede Zustellung scheitert an der Prüfung. Der Leitfaden zu selbst gehostetem n8n behandelt die Reverse-Proxy-Seite, und der Beitrag zum n8n-URL-Shortener zeigt, was Sie bauen können, sobald Ereignisse eintreffen.
Wie sich Elido von Stripe, GitHub und Standard Webhooks unterscheidet
Wenn Sie einen Prüfer für einen anderen Anbieter geschrieben haben, kopieren Sie ihn nicht blind. Geprüft gegen die Dokumentation jedes Anbieters am 2026-10-25:
- Stripe legt
t=und einen oder mehrerev1=-Werte in einen einzigen HeaderStripe-Signature, signierttimestamp.bodymit HMAC-SHA256 in Hex, und seine Bibliotheken setzen standardmäßig eine Toleranz von fünf Minuten (Stripe-Doku). Elido legt den Zeitstempel in einen eigenen Header. - GitHub signiert nur den Rohkörper, sendet
sha256=plus Hex inX-Hub-Signature-256und rät zu einem Vergleich in konstanter Zeit (GitHub-Doku). Es gibt keinen Zeitstempel zu prüfen. - Standard Webhooks nutzt
webhook-id,webhook-timestampundwebhook-signature, signiertid.timestamp.body, kodiert in Base64 mit einem Präfixv1,und überlässt den Toleranzwert Ihnen (Spezifikation). ElidosX-Webhook-Deliveryübernimmt die Rolle der ID, ist aber nicht Teil der signierten Nachricht.
Elidos eigenes Schema ist das in signing.Sign in api-core: Die Nachricht ist der Unix-Zeitstempel, ein Punkt und der Körper, der Schlüssel ist der Secret-String wie gespeichert, und das Ergebnis ist v1= plus Hex in Kleinbuchstaben. Elido hat kein Ereignis link.clicked, erwarten Sie auf diesem Kanal also keine Zustellungen auf Klickebene.
Replay-Fenster und Secret-Rotation
Eine gültige Signatur beweist, wer eine Anfrage gesendet hat, nicht wann. Wer eine signierte Zustellung abfängt, aus einer Log-Zeile oder einem falsch konfigurierten Proxy, könnte sie eine Woche später erneut senden, und der HMAC würde weiterhin passen. Die Zeitstempelprüfung schließt diese Lücke, und sie ist Ihre Aufgabe: Elidos Zustellungs-Worker signiert den Zeitstempel, erzwingt aber auf Ihrer Seite kein Fenster. Ich nutze 300 Sekunden. Halten Sie die Uhr des Empfängers per NTP synchron, denn ein Server, der um ein paar Minuten abdriftet, beginnt ehrlichen Traffic abzulehnen.
Retries lösen das Fenster nicht aus. Jeder Versuch erhält einen frischen X-Webhook-Timestamp und eine neue Signatur, während X-Webhook-Delivery gleich bleibt. Diese Trennung gibt Ihnen beide Abwehrmaßnahmen: Der Zeitstempel begrenzt, wie lange eine abgefangene Anfrage nutzbar bleibt, und ein eindeutiger Index auf der Delivery-ID verhindert, dass ein legitimer Retry zweimal verarbeitet wird. Der Beitrag zu Rate Limits und Idempotenz behandelt dasselbe Muster auf der eingehenden API-Seite.
Die Rotation läuft über POST /v1/workspaces/{workspace_id}/webhooks/{id}/rotate-secret oder den Button Rotate auf der Endpunktseite. Die Antwort enthält das neue Secret einmalig, plus grace_window_days (7) und previous_expires_at. In diesen sieben Tagen trägt jede Zustellung zwei Signaturen:
X-Elido-Signature, erzeugt mit dem neuen SecretX-Elido-Signature-Previous, erzeugt mit dem alten
Deshalb prüft jedes Snippet oben beide Header gegen das eine Secret, das es hält. Ein Empfänger, der noch das alte Secret nutzt, passt auf den zweiten Header; nachdem Sie das neue ausgerollt haben, passt er auf den ersten. Dazwischen scheitert nichts. Betrachten Sie den Previous-Header aber als Brücke statt als Garantie und rollen Sie das neue Secret früh in der Woche aus.
Warum die Prüfung der Webhook-Signatur fehlschlägt
Wenn ich jemandem beim Debuggen helfe, ist es fast jedes Mal dieselbe kurze Liste. Gehen Sie sie der Reihe nach durch:
- Neu serialisiertes JSON.
JSON.stringify(req.body)oderjson.dumps(payload)erzeugt andere Bytes als die, die der Absender gehasht hat: Schlüsselreihenfolge, Leerzeichen, escapte Schrägstriche, Unicode-Escapes. Hashen Sie den Rohkörper. Wenn Ihr Framework ihn bereits geparst hat, korrigieren Sie die Reihenfolge der Middleware, statt den String nachzubauen. - Die falschen Schlüssel-Bytes. Elido nutzt den gesamten
whsec_...-String als HMAC-Schlüssel. Das Präfix zu entfernen, den Rest aus Hex zu dekodieren oder aus Base64 zu dekodieren (was Bibliotheken für Standard Webhooks tun) ergibt einen anderen Schlüssel. Ein abschließender Zeilenumbruch durchechoin eine Secrets-Datei bewirkt dasselbe, weshalb das Python-Beispiel.strip()aufruft. - Kodierungskonflikt. Vergleichen Sie
v1=plus Hex in Kleinbuchstaben mit dem Header. Ein Base64-Digest, ein Hex-String in Großbuchstaben oder ein fehlendes Präfix passt nie. - Der falsche Zeitstempel. Nutzen Sie den Header-String
X-Webhook-Timestamp, nicht das Feldtimestampdes Körpers und keine Zahl, die Sie geparst und neu formatiert haben.
Zwei kleinere: Vergleichen mit == funktioniert, verrät aber Timing, nutzen Sie also die Funktion für konstante Zeit, die Ihre Sprache mitbringt; und ein Proxy, der Körper dekomprimiert oder neu kodiert, bricht die Prüfung ebenfalls, was bei einfachen JSON-POSTs aber selten ist.
Wenn beide Seiten weiterhin uneins sind, protokollieren Sie den Zeitstempel, die Länge des Körpers und die ersten Hex-Zeichen Ihres Digests und führen Sie dann die openssl-Zeile von oben mit denselben Eingaben aus. Die Seite, die mit openssl übereinstimmt, ist die richtige. Wo das Signieren unter den anderen Kontrollen einzuordnen ist, die Sie bei jedem Anbieter prüfen sollten, zeigt die Sicherheits-Checkliste für URL-Shortener.
Lesen Sie den Cornerstone: Webhooks für Link-Ereignisse.
Weitere Beiträge im Blog
- Webhooks für Link-Ereignisse - Ereignistypen, Payload-Hülle und Retry-Richtlinie.
- Webhooks vs Polling für Klick-Tracking - wann Push Pull schlägt und wann nicht.
- Selbst gehostete Link-Automatisierung mit n8n - Reverse Proxy, Queue-Modus und Signaturprüfung in einem Stack.
- URL-Shortener-API: Rate Limits, Retries, Idempotenz - die Dedupe-Muster aus der Gegenrichtung.
- Sicherheits-Checkliste für URL-Shortener - neun Kontrollen, die Sie bei jedem Anbieter prüfen sollten.
Häufig gestellte Fragen
Wie prüfe ich eine Webhook-Signatur?
Berechnen Sie den HMAC über genau das, was der Absender signiert hat, mit dem gemeinsamen Secret, und vergleichen Sie Ihr Ergebnis in konstanter Zeit mit dem Signatur-Header. Bei Elido bedeutet das HMAC-SHA256 über den Wert von X-Webhook-Timestamp, einen Punkt und den Rohkörper, hexkodiert mit dem Präfix v1=. Lehnen Sie die Anfrage ab, wenn nichts passt oder der Zeitstempel veraltet ist.
Warum schlägt meine Prüfung der Webhook-Signatur immer wieder fehl?
Fast immer, weil Sie andere Bytes gehasht haben als der Absender. Zuvor lief ein JSON-Body-Parser und Sie haben das Objekt neu serialisiert, oder Sie haben das Secret dekodiert, oder Sie haben Hex mit Base64 verglichen. Hashen Sie die rohen Anfrage-Bytes, nutzen Sie den Secret-String exakt wie ausgegeben und protokollieren Sie beide Werte nebeneinander.
Was ist HMAC in einem Webhook?
HMAC ist ein Hash mit Schlüssel: Der Absender mischt ein Secret, das er mit Ihnen teilt, in einen SHA-256-Hash der Nachricht. Nur wer das Secret besitzt, kann einen passenden Wert erzeugen, eine gültige Signatur beweist also, dass die Anfrage vom Absender kam und der Körper unterwegs nicht verändert wurde.
Wie verhindere ich Replay-Angriffe auf Webhooks?
Signieren Sie den Zeitstempel zusammen mit dem Körper und lehnen Sie jede Anfrage ab, deren Zeitstempel mehr als ein paar Minuten alt ist; fünf Minuten sind ein gängiges Fenster. Speichern Sie dann die Delivery-ID und überspringen Sie bereits verarbeitete IDs. Elido signiert den Zeitstempel, überlässt die Prüfung der Aktualität aber Ihrem Empfänger.
Soll ich für eine HMAC-SHA256-Webhook-Signatur Hex oder Base64 verwenden?
Was der Absender dokumentiert, denn die beiden Kodierungen desselben Digests sind nie gleich. Elido sendet Hex in Kleinbuchstaben nach einem v1=-Präfix. Shopify und die Standard-Webhooks-Spezifikation nutzen Base64, und GitHub nutzt Hex nach sha256=. Kodieren Sie Ihren Digest vor dem Vergleich auf dieselbe Weise.
Wie rotiere ich ein Webhook-Secret, ohne Ereignisse zu verlieren?
Nutzen Sie einen Absender, der eine Weile mit beiden Schlüsseln signiert. Nach der Rotation eines Elido-Endpunkt-Secrets trägt jede Zustellung sieben Tage lang X-Elido-Signature mit dem neuen Schlüssel und X-Elido-Signature-Previous mit dem alten. Akzeptieren Sie beide Header, rollen Sie das neue Secret aus, und das alte läuft von selbst ab.
Elido testen
URL einfügen, kurzer Link in Sekunden
Kein Konto nötig. Link bleibt 30 Tage aktiv. Konto erstellen, um ihn dauerhaft zu behalten.
Keine Anmeldung nötig. Kostenlos registrieren für 50 Links im Monat.