10 Min. LesezeitEngineering

Webhook-Signaturen prüfen: HMAC-SHA256 in Node, Python und Go

So prüfen Sie eine Webhook-Signatur mit HMAC-SHA256: roher Body, zeitkonstanter Vergleich, Replay-Fenster und Geheimnisrotation mit Node-, Python-, Go- und n8n-Code.

Marius Voß
DevRel · edge infra
Cover im Pixelstil: So werden Webhook-Signatur-Header geprüft, indem ein Zeitstempel und ein roher Body mit HMAC-SHA256 zu einem v1= Hexwert gehasht und neben einem Pixel-Schlüssel zeitkonstant verglichen werden

Um eine Webhook-Signatur zu prüfen, berechnen Sie einen HMAC über genau die Bytes neu, die der Absender signiert hat, verwenden dabei das mit ihm geteilte Geheimnis und vergleichen Ihren Wert zeitkonstant mit der Signatur im Header. Prüfen Sie anschließend, ob der signierte Zeitstempel aktuell ist. Bei Elido-Webhooks bedeutet das HMAC-SHA256 mit Ihrem vollständigen whsec_...-Geheimnis als Schlüssel über {X-Webhook-Timestamp}.{raw body}, hexadezimal codiert, mit v1= präfixiert und mit X-Elido-Signature verglichen.

Das ist der gesamte Algorithmus. Die Fehler stecken in den Details: ein Body-Parser, der zu früh lief, ein dekodiertes Geheimnis oder ein Hex-Digest, der mit einem Base64-Digest verglichen wurde. Dieser Beitrag behandelt die Prüfung von HMAC-Webhook-Signaturen allgemein und zeigt anschließend funktionierenden Elido-Code für Node, Python, Go und einen n8n-Code-Knoten sowie das Replay-Fenster und die Geheimnisrotation, die in den meisten Schnellstarts fehlen.

Wenn Sie noch keinen Endpunkt eingerichtet haben, beginnen Sie mit Webhooks für Link-Ereignisse. Dort sind alle Ereignistypen und die Nutzlast-Hülle aufgeführt. Diese Seite setzt ein, sobald eine signierte Anfrage auf Ihrem Server eintrifft.

So funktionieren HMAC-Webhook-Signaturen

Ein Webhook-Endpunkt ist eine öffentliche URL. Jeder, der sie findet, kann einen JSON-Body senden, der wie ein echtes Ereignis aussieht. Der Empfänger braucht daher einen Herkunftsnachweis. HMAC liefert ihn mit geringem Aufwand: Absender und Empfänger teilen ein Geheimnis, der Absender berechnet HMAC-SHA256(secret, message) und legt das Ergebnis in einem Header ab, und der Empfänger führt dieselbe Berechnung durch und vergleicht die Werte. Ohne das Geheimnis kann niemand einen passenden Wert erzeugen, und schon die Änderung eines einzelnen Bytes der Nachricht verändert den gesamten Digest.

Drei Details unterscheiden sich zwischen Anbietern. Bei jedem Fehler bricht die Prüfung:

  1. Was in die Nachricht eingeht. GitHub signiert nur den rohen Body. Stripe und Elido signieren einen Zeitstempel, einen Punkt und den Body. Die Standard-Webhooks-Spezifikation signiert eine Nachrichten-ID, den Zeitstempel und den Body.
  2. Wie der Digest codiert wird. Hex oder Base64, mit einem Schema-Präfix wie v1= oder sha256=.
  3. Was der Schlüssel ist. Einige Anbieter dekodieren das Geheimnis nach seinem Präfix aus Base64. Elido tut das nicht: Der Schlüssel ist der vollständige whsec_-String als UTF-8-Bytes.

Der Zeitstempel muss in der signierten Nachricht enthalten sein. So wird verhindert, dass ein Angreifer einen alten, gültig signierten Body mit einem aktuellen Zeitstempel-Header kombiniert. Erst dadurch lässt sich überhaupt ein Replay-Fenster durchsetzen.

So wird eine Webhook-Signatur geprüft: Elido verbindet Unix-Zeitstempel, Punkt und rohen Body, hasht sie mit HMAC-SHA256 unter dem whsec_-Geheimnis und sendet v1= Hex in X-Elido-Signature; der Empfänger berechnet denselben Wert aus den rohen Bytes und dem Zeitstempel-Header neu und vergleicht ihn zeitkonstant

Was Elido signiert und welche Header das übertragen

Jede Zustellung an einen event- oder siem-Endpunkt ist ein POST mit Content-Type: application/json und einem Body in der Form {"type", "workspace_id", "data", "timestamp"}. Chatartige Endpunktarten wie Discord, Telegram und Sentry authentifizieren sich über ihre URL und übertragen keine HMAC-Header. Alles Folgende gilt daher nur für die ersten beiden Arten.

HeaderWertWas damit zu tun ist
X-Elido-Signaturev1= + 64 Hexzeichen in KleinbuchstabenMit Ihrem berechneten Wert vergleichen
X-Webhook-SignatureDerselbe Wert wie obenÄlteres Alias; entweder den einen oder den anderen lesen
X-Webhook-TimestampUnix-Sekunden, z. B. 1789000000Teil der signierten Nachricht; Alter prüfen
X-Elido-Signature-Previousv1= + Hex, mit dem alten Geheimnis signiertNur während eines Rotationsfensters vorhanden
X-Webhook-EventEreignisname, z. B. link.createdEreignis weiterleiten, nachdem es geprüft wurde
X-Webhook-DeliveryNumerische Zustell-ID, bei Wiederholungen stabilDeduplizierungsschlüssel für idempotente Verarbeitung

Das Geheimnis wird beim Erstellen des Endpunkts für Sie erzeugt: whsec_ gefolgt von 64 Hexzeichen. Es wird einmal in der Erstellungsantwort zurückgegeben und danach nie wieder, also gehört es direkt in Ihren Geheimnisspeicher.

Hier ist ein Testvektor, mit dem Sie Ihren Code prüfen können. Mit dem Geheimnis whsec_test_only_do_not_use, dem Zeitstempel 1789000000 und dem Body {"type":"link.created","workspace_id":42} lautet der korrekte Header-Wert:

v1=b9369aa411a8b7ce705bcd5bba112dea9d72d2e787aa88959ff62f33942d1a15

Sie können ihn über eine Shell reproduzieren. Das ist mein erster Schritt, wenn Empfänger und Absender unterschiedliche Ergebnisse melden:

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

Eine Falle: Das timestamp-Feld des Bodys bezeichnet den Zeitpunkt, zu dem das Ereignis passiert ist. Der signierte Zeitstempel steht im Header X-Webhook-Timestamp und wird beim Senden der Anfrage gesetzt. Verwechseln Sie die beiden nicht.

Webhook-Signatur in Node prüfen

In Express besteht die Lösung für die meisten Fehler aus einer Zeile: Binden Sie express.raw() an die Webhook-Route, sodass req.body ein Buffer mit genau den empfangenen Bytes ist. Registrieren Sie diese Route vor jedem globalen app.use(express.json()), denn sobald der JSON-Parser den Stream gelesen hat, gibt es für den 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 löst bei Buffern unterschiedlicher Länge einen Fehler aus, statt false zurückzugeben. Wenn Sie das TypeScript-SDK aus dem Schnellstart für API und SDKs verwenden, führt webhooks.verify() in @elido/sdk denselben HMAC und denselben zeitkonstanten Vergleich durch. Übergeben Sie jedoch ausdrücklich { maxSkewSec: 300 }: Ohne diese Option wird das Alter des Zeitstempels überhaupt nicht geprüft.

HMAC-SHA256-Webhooksignaturen in Python und Go prüfen

Die Python-Standardbibliothek deckt das ab. Mit FastAPI gibt await request.body() die rohen Bytes zurück. In Flask rufen Sie request.get_data() auf, bevor irgendetwas auf request.json zugreift.

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}

Lesen Sie in Go den Body einmal, begrenzen Sie seine Größe und verwenden Sie hmac.Equal aus crypto/hmac, das zeitkonstant vergleicht. Ich erstelle die Nachricht direkt aus dem Header-String, wie er empfangen wurde, 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 Varianten antworten bei einer ungültigen Signatur mit 401 und parsen JSON erst, nachdem die Prüfung erfolgreich war. Elido wertet jeden Status außerhalb von 2xx als fehlgeschlagenen Versuch. Wenn Ihre Prüfung fehlerhaft ist, sammeln sich echte Zustellungen als 401er im Zustellprotokoll des Endpunkts. Nach einer Bereitstellung ist das die erste Stelle, die Sie prüfen sollten.

Möchten Sie das mit echtem Datenverkehr sehen? Erstellen Sie einen Endpunkt in einem Workspace über die Webhook-Feature-Seite und leiten Sie ihn auf einen lokalen Tunnel. Das Zustellprotokoll zeigt den Statuscode, den Ihre Prüfung für jeden Versuch zurückgegeben hat.

Webhook-Signatur in einem n8n-Code-Knoten prüfen

n8n kann dieselbe Prüfung ohne zusätzlichen Dienst durchführen. Aktivieren Sie die Option für den rohen Body im Webhook-Knoten. Dadurch wird die unveränderte Anfrage als Binärdaten gespeichert. Fügen Sie anschließend direkt danach einen Code-Knoten ein. Das integrierte crypto-Modul ist im n8n-Code-Knoten erlaubt, daher läuft dies 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 ausgelöster Fehler beendet die Ausführung, sodass nachgelagerte Schritte bei einem gefälschten Ereignis nicht laufen. Ein Hinweis zum Self-Hosting: Wenn Ihre Instanz N8N_BLOCK_ENV_ACCESS_IN_NODE=true setzt, kann der Code-Knoten $env nicht lesen. Das Geheimnis bleibt leer und jede Zustellung scheitert an der Prüfung. Der n8n-Leitfaden für Self-Hosting behandelt die Seite des Reverse-Proxys. Der n8n-Beitrag zum URL-Shortener zeigt, was Sie bauen können, sobald Ereignisse eintreffen.

Replay-Fenster und Geheimnisrotation

Eine gültige Signatur beweist, wer eine Anfrage gesendet hat, aber nicht wann. Jemand, der eine signierte Zustellung aus einer Protokollzeile oder einem falsch konfigurierten Proxy abfängt, 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 Zustell-Worker signiert den Zeitstempel, erzwingt aber auf Ihrer Seite kein Fenster. Ich verwende 300 Sekunden. Halten Sie die Uhr des Empfängers mit NTP synchron, denn ein Server, der einige Minuten vor- oder nachgeht, beginnt sonst, legitimen Datenverkehr abzulehnen.

Zustellwiederholungen (Retries) kollidieren nicht mit dem Replay-Fenster. Jeder Versuch erhält einen neuen X-Webhook-Timestamp und eine neue Signatur, während X-Webhook-Delivery gleich bleibt. Diese Trennung liefert beide Schutzmechanismen: Der Zeitstempel begrenzt, wie lange eine abgefangene Anfrage verwendbar bleibt, und ein eindeutiger Index auf der Zustell-ID verhindert, dass eine legitime Wiederholung zweimal verarbeitet wird. Der Beitrag zu Ratenlimits und Idempotenz behandelt dasselbe Muster auf der Seite der eingehenden API.

Die Rotation erfolgt über POST /v1/workspaces/{workspace_id}/webhooks/{id}/rotate-secret oder über die Schaltfläche Rotate auf der Endpunktseite. Die Antwort enthält das neue Geheimnis einmalig sowie grace_window_days (7) und previous_expires_at. Während dieser sieben Tage enthält jede Zustellung zwei Signaturen:

  • X-Elido-Signature, erstellt mit dem neuen Geheimnis
  • X-Elido-Signature-Previous, erstellt mit dem alten Geheimnis

Deshalb prüft jedes obige Beispiel beide Header gegen das eine Geheimnis, das es besitzt. Ein Empfänger, der noch das alte Geheimnis verwendet, stimmt mit dem zweiten Header überein. Nachdem Sie das neue Geheimnis bereitgestellt haben, stimmt er mit dem ersten überein. Dazwischen schlägt nichts fehl. Betrachten Sie den Header des vorherigen Schlüssels dennoch als Brücke und nicht als Garantie und spielen Sie das neue Geheimnis früh in der Woche ein.

Zeitachse der Webhook-Geheimnisrotation: Vor der Rotation wird nur X-Elido-Signature gesendet; nach rotate-secret tragen Zustellungen während eines siebentägigen Übergangsfensters X-Elido-Signature mit dem neuen Schlüssel und X-Elido-Signature-Previous mit dem alten Schlüssel, sodass ein Empfänger mit einem der beiden Geheimnisse prüfen kann; nach dem Fenster signiert nur noch der neue Schlüssel

Warum die Prüfung von Webhook-Signaturen fehlschlägt

Wenn ich jemandem beim Debuggen helfe, ist es fast jedes Mal dieselbe kurze Liste. Arbeiten Sie sie der Reihe nach durch:

  1. JSON neu serialisiert. JSON.stringify(req.body) oder json.dumps(payload) erzeugt andere Bytes als die, die der Absender gehasht hat: Schlüsselreihenfolge, Leerzeichen, maskierte Schrägstriche und Unicode-Escapes. Hashen Sie den rohen Body. Wenn Ihr Framework ihn bereits geparst hat, korrigieren Sie die Reihenfolge der Middleware, statt den String neu aufzubauen.
  2. Die falschen Schlüsselbytes. Elido verwendet den vollständigen whsec_...-String als HMAC-Schlüssel. Wenn Sie das Präfix entfernen, den Rest hexadezimal dekodieren oder aus Base64 dekodieren, wie es Bibliotheken für Standard Webhooks tun, erhalten Sie einen anderen Schlüssel. Ein nachgestellter Zeilenumbruch, den echo in eine Geheimnisdatei schreibt, hat denselben Effekt. Deshalb ruft das Python-Beispiel .strip() auf.
  3. Nicht passende Codierung. Vergleichen Sie v1= plus Kleinbuchstaben-Hex mit dem Header. Ein Base64-Digest, eine Hex-Zeichenfolge in Großbuchstaben oder ein fehlendes Präfix stimmen nie überein.
  4. Der falsche Zeitstempel. Verwenden Sie den String aus dem Header X-Webhook-Timestamp, nicht das timestamp-Feld des Bodys und keine Zahl, die Sie geparst und neu formatiert haben.

Zwei kleinere Punkte: Ein Vergleich mit == funktioniert zwar, verrät aber Timing-Informationen. Verwenden Sie daher die zeitkonstante Funktion Ihrer Sprache. Außerdem bricht ein Proxy, der Bodys dekomprimiert oder neu codiert, die Prüfung ebenfalls, auch wenn das bei einfachen JSON-POSTs selten vorkommt.

Wenn beide Seiten weiterhin unterschiedliche Ergebnisse liefern, protokollieren Sie den Zeitstempel, die Länge des Bodys und die ersten Hexzeichen Ihres Digests. Führen Sie anschließend die obige openssl-Zeile mit denselben Eingaben aus. Die Seite, deren Ergebnis mit openssl übereinstimmt, ist die richtige. Informationen dazu, wo die Signierung neben den anderen wichtigen Kontrollen eines Anbieters einzuordnen ist, finden Sie in der Sicherheits-Checkliste für URL-Shortener.

Lesen Sie den Grundlagenbeitrag: Webhooks für Link-Ereignisse.

Verwandte Beiträge im Blog

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 Geheimnis neu und vergleichen Sie Ihr Ergebnis zeitkonstant mit der Signatur im Header. Bei Elido bedeutet das HMAC-SHA256 über den Wert von X-Webhook-Timestamp, einen Punkt und den rohen Body, hexadezimal codiert mit dem Präfix v1=. Lehnen Sie die Anfrage ab, wenn nichts übereinstimmt oder der Zeitstempel veraltet ist.

Warum schlägt die Prüfung meiner Webhook-Signatur ständig fehl?

Fast immer, weil Sie andere Bytes gehasht haben als der Absender. Ein JSON-Body-Parser lief zuerst und Sie haben das Objekt neu serialisiert, oder Sie haben das Geheimnis dekodiert, oder Sie haben Hex mit Base64 verglichen. Hashen Sie die rohen Anfragebytes, verwenden Sie den Geheimnis-String genau wie ausgegeben und protokollieren Sie beide Werte nebeneinander.

Was ist HMAC bei einem Webhook?

HMAC ist ein schlüsselgebundener Hash: Der Absender mischt ein Geheimnis, das er mit Ihnen teilt, in einen SHA-256-Hash der Nachricht. Nur wer das Geheimnis besitzt, kann einen passenden Wert erzeugen. Eine gültige Signatur beweist daher, dass die Anfrage vom Absender stammt und der Body unterwegs nicht verändert wurde.

Wie verhindere ich Replay-Angriffe auf Webhooks?

Signieren Sie den Zeitstempel zusammen mit dem Body und lehnen Sie jede Anfrage ab, deren Zeitstempel mehr als einige Minuten zurückliegt. Fünf Minuten sind ein übliches Fenster. Speichern Sie anschließend die Zustell-ID und überspringen Sie IDs, die Sie bereits verarbeitet haben. Elido signiert den Zeitstempel, überlässt die Aktualitätsprüfung aber Ihrem Empfänger.

Soll ich Hex oder Base64 für eine HMAC-SHA256-Webhook-Signatur verwenden?

Das, was der Absender dokumentiert, denn die beiden Codierungen desselben Digests sind niemals gleich. Elido sendet Kleinbuchstaben-Hex nach einem v1=-Präfix. Shopify und die Standard-Webhooks-Spezifikation verwenden Base64, GitHub verwendet Hex nach sha256=. Codieren Sie Ihren Digest vor dem Vergleich auf dieselbe Weise.

Wie rotiere ich ein Webhook-Geheimnis, ohne Ereignisse zu verlieren?

Verwenden Sie einen Absender, der eine Zeit lang mit beiden Schlüsseln signiert. Nachdem Sie das Geheimnis eines Elido-Endpunkts rotiert haben, enthält jede Zustellung X-Elido-Signature mit dem neuen Schlüssel und sieben Tage lang X-Elido-Signature-Previous mit dem alten. Akzeptieren Sie beide Header, spielen Sie das neue Geheimnis ein, 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.

Kostenlos, keine Anmeldung erforderlich · 2 pro Tag

Elido testen

URL-Shortener mit EU-Hosting: eigene Domains, tiefe Analytik und eine offene API. Kostenloser Tarif - keine Kreditkarte nötig.

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

Weiterlesen