Anleitungen

Anleitungen12 Min. Lesezeit

Webhook-Signaturen von Formularen prüfen

Prüfe, ob ein Formular-Webhook wirklich von Ikiform kommt: das HMAC-SHA256-Schema von Standard Webhooks, ein Testvektor, Raw Bodys, Zeitstempel und Secret-Rotation.

Preet SutharGründer, Ikiform

Berechne die Signatur selbst neu und vergleiche sie mit der im Request. Ikiform folgt Standard Webhooks: Die Signatur ist ein base64-kodierter HMAC-SHA256 aus webhook-id, webhook-timestamp und dem Raw Body, mit Punkten verbunden, als Schlüssel dient der base64-dekodierte Teil deines whsec_-Secrets, gesendet als v1,<signature>. Akzeptiere den Request nur, wenn eine v1-Signatur in einem Vergleich mit konstanter Laufzeit übereinstimmt und der Zeitstempel höchstens 5 Minuten abweicht.

Ein Webhook-Endpunkt ist eine öffentliche URL, die POST-Requests von jedem annimmt. Ohne Signaturprüfung kann jeder, der die URL kennt, gefälschte Einsendungen in dein CRM, deine Datenbank oder den Chat deines Teams schicken. Die Prüfung sind ein paar Zeilen Code, und genau darum, diese Zeilen exakt richtig hinzubekommen, geht es in dieser Anleitung.

So funktioniert das Signieren

Ikiform und dein Server teilen ein Secret, das einmal angezeigt wird, wenn du den Endpunkt hinzufügst. Vor jedem Zustellversuch hasht Ikiform den Request mit diesem Secret und legt das Ergebnis in einen Header. Dein Server wiederholt die Berechnung mit dem, was er empfangen hat. Nur wer das Secret kennt, kann einen passenden Wert erzeugen, und jede Änderung an Body, ID oder Zeitstempel macht ihn ungültig.

Wie Ikiform eine Webhook-Zustellung signiert und wie dein Server sie prüftIkiform, bei jedem Versuchdein HandlerJSON-Bodysubmission.createdid.timestamp.bodyder signierteInhaltHMAC-SHA256mit jedemaktiven Secretwebhook-signaturev1,<base64>Raw Body +Headervor jedemParsengleicherHMACmit deinerKopie desSecretsVergleichinkonstanterZeitein v1 passt,Zeitstempelfrisch2xxdann parsen undverarbeiten400nichtsverarbeitetkein TrefferWie Ikiform eine Webhook-Zustellung signiert und wie dein Server sie prüftIkiform, bei jedem Versuchdein HandlerJSON-Bodysubmission.createdid.timestamp.bodyder signierteInhaltHMAC-SHA256mit jedem aktivenSecretwebhook-signaturev1,<base64>Raw Body +Headervor jedem Parsengleicher HMACmit deiner Kopiedes SecretsVergleich inkonstanter Zeitein v1 passt,Zeitstempel frisch2xxdann parsen undverarbeiten400nichtsverarbeitetkein Treffer
  1. Ikiform serialisiert das Event als JSON.
  2. Es verbindet die Zustellungs-ID, den Unix-Zeitstempel des Versuchs und den Body mit Punkten.
  3. Es berechnet einen HMAC-SHA256 dieses Strings mit jedem aktiven Secret des Endpunkts.
  4. Es sendet jedes Ergebnis base64-kodiert und mit dem Präfix v1 im Header webhook-signature.
  5. Dein Handler liest den Raw Body und die drei Header, bevor er irgendetwas parst.
  6. Er berechnet denselben HMAC mit seiner Kopie des Secrets.
  7. Er vergleicht mit konstanter Laufzeit gegen jede v1-Signatur und prüft, ob der Zeitstempel höchstens 5 Minuten abweicht.
  8. Bei einer Übereinstimmung antwortet er mit 2xx und verarbeitet das Event. Sonst antwortet er mit 400 und tut nichts.
Mit dem Request reist nichts Geheimes. Die Signatur beweist, dass der Absender das Secret kannte und die Bytes unterwegs nicht verändert wurden.

Das Schema im Detail

Ikiform nutzt Standard Webhooks, eine offene Spezifikation, die mehrere Webhook-Anbieter teilen. Jede Zustellung trägt drei Header:

HeaderWertHinweise
webhook-iddlv_...Die Zustellungs-ID. Bei jedem erneuten Versuch gleich, dient also auch als Idempotenzschlüssel
webhook-timestampUnix-Sekunden, etwa 1792300000Wann dieser Versuch signiert wurde. Bei jedem erneuten Versuch neu
webhook-signaturev1, plus base64Ein oder mehrere durch Leerzeichen getrennte Werte. Zwei, während ein Secret rotiert wird
Was signiert wird
webhook-id:        dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
raw body:          {"id":"dlv_01k7...","type":"endpoint.test",...}

signed content = webhook-id + "." + webhook-timestamp + "." + raw body
key            = base64-decode(secret without "whsec_")
signature      = base64(HMAC-SHA256(key, signed content))
header         = "v1," + signature

So prüfst du eine Zustellung, in dieser Reihenfolge:

  1. Lies den Raw Body als Bytes oder exakten String. Parse ihn noch nicht.
  2. Lies die drei Header. Fehlt einer, lehne ab.
  3. Prüfe, ob der Zeitstempel eine ganze Zahl ist und höchstens 5 Minuten von deiner Uhr abweicht, in beide Richtungen. Wenn nicht, lehne ab.
  4. Entferne whsec_ von deinem Secret und dekodiere den Rest mit base64. Diese 32 Bytes sind der HMAC-Schlüssel, nicht der String selbst.
  5. Berechne den HMAC-SHA256 von {webhook-id}.{webhook-timestamp}.{raw body} und kodiere ihn mit base64, mit dem Standardalphabet und Padding.
  6. Teile webhook-signature an den Leerzeichen. Teile jeden Eintrag am ersten Komma; überspringe alles, dessen Version nicht v1 ist.
  7. Vergleiche jeden v1-Wert mit deinem in konstanter Laufzeit. Stimmt einer überein, ist die Zustellung echt.
  8. Parse erst jetzt das JSON und reagiere darauf.

Ein Testvektor

Bevor du einen Handler deployst, beweise, dass er den richtigen Wert berechnet. Diese Eingaben sind ein echter endpoint.test-Zustellungsbody, signiert mit einem erfundenen Secret. Dein Code muss exakt die Signatur in der letzten Zeile erzeugen. Die Anleitungen für jede Sprache enthalten einen Unit-Test, der sie verwendet.

EingabeWert
Secretwhsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=
webhook-iddlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp1792300000
Raw BodyExakt die Bytes im Skript unten, ohne Leerzeichen oder Zeilenumbruch
Erwartete webhook-signaturev1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=

Du kannst es ohne Code prüfen. Das hier läuft in einem Terminal unter macOS oder Linux und gibt den base64-Teil der Signatur aus:

Terminal
SECRET='whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw='
ID='dlv_01k7v2d4n8r2p6s0w3y5b7c9e1'
TS='1792300000'
BODY='{"id":"dlv_01k7v2d4n8r2p6s0w3y5b7c9e1","type":"endpoint.test","timestamp":"2026-10-17T12:00:00.000Z","api_version":"2026-10-17","data":{"endpoint_id":"whk_01k7v2e1b3d5f7h9k1m3n5q7s9","message":"This is a test event from Ikiform."}}'

KEY=$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)
printf '%s.%s.%s' "$ID" "$TS" "$BODY" \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY" -binary \
  | base64

Zwei weitere Fälle verdienen je einen Test, weil sie die zwei häufigsten echten Fehler sind:

ÄnderungSignaturSollte dein Handler sie akzeptieren?
Derselbe Body, formatiert mit zwei Leerzeichen Einrückungv1,se0TDhknrG0hYMvCId83HnpZfJcELG3ozxFJktRUHqY=Nein. Gleiches JSON, andere Bytes
webhook-timestamp eine Sekunde späterv1,oBfWyLDPfrwVSo40e6b7B7+LmKkZX7OqT88SsrpY6ks=Nein. Der Zeitstempel ist signiert
Während einer Rotation mit einem zweiten Secret signiertv1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=Ja, wenn der Header auch einen Wert enthält, zu dem dein Secret passt

Während einer Secret-Rotation trägt der Header beide Werte. Dein Handler muss das hier akzeptieren, egal welches der beiden Secrets er hat:

Zwei Signaturen
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=
Der Zeitstempel im Vektor ist fest, daher lehnt ein echter Verifier ihn als zu alt ab. Tests müssen eine feste aktuelle Zeit übergeben, wie es die Anleitungen für jede Sprache tun. Der Signatur-Verifier auf der Webhooks-Seite prüft beliebige Secrets, Header und Bodys im Browser.

Wege zur Prüfung

AnsatzStark beiNachteile
verifyWebhook aus @ikiform/sdk/webhooksEin Aufruf, der alles prüft und ein typisiertes Event zurückgibt. Läuft auf Node, Workers, Deno und BunNur JavaScript und TypeScript
Eine Standard-Webhooks-BibliothekGepflegter Code für Python, Go, PHP, Ruby, JavaScript, Rust, Java, C# und ElixirEine Abhängigkeit mehr. Prüfe, wie jede die Header übergeben haben will; manche unterscheiden Groß- und Kleinschreibung
Dein eigener HMAC-CodeKeine Abhängigkeit, etwa 20 Zeilen, und du kannst jeden Zweig testenLeicht auf subtile Weise falsch: String-Vergleich, falsche Schlüssel-Bytes oder ein geparster Body
Stattdessen ein geheimer Pfad oder Query-TokenEinfachLeakt über Logs, Proxys und Error-Tracker, beweist nichts über den Body und kann nicht ablaufen. Kein Ersatz

Ikiform signiert jede Zustellung, und die Signatur ist der unterstützte Weg, eine zu authentifizieren. Die Doku veröffentlicht keine feste Liste sendender IP-Adressen, daher ist eine IP-Allowlist keine Prüfung, auf die du dich verlassen kannst.

In deinem Framework prüfen

Für JavaScript-Stacks übernimmt das SDK die Arbeit. Zwischen Frameworks ändert sich, wo die Route liegt und wie du an den Body kommst, bevor ihn etwas parst. Wähle deins:

Ein Route Handler im App Router. request.text() ist der Raw Body; ein vorheriger Aufruf von request.json() würde ihn verlieren. Die Anleitung für den eigenen Server ergänzt Deduplizierung und after().

app/api/webhooks/ikiform/route.ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export async function POST(request: Request) {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    console.info(event.type, request.headers.get("webhook-id"));
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
}

Jeder Handler antwortet bei Erfolg mit 204 und sonst mit 400 samt Fehlergrund. Erledige langsame Arbeit nach der Antwort; Ikiform wartet 15 Sekunden, bevor es einen Versuch als fehlgeschlagen zählt.

Warum der Raw Body zählt

Die Signatur deckt Bytes ab, nicht Bedeutung. {"a":1} und { "a": 1 } sind dasselbe JSON, aber unterschiedliche Bytes, daher passt ein Body, der geparst und erneut serialisiert wurde, fast nie. Dasselbe gilt für alles, was den Body auf dem Weg hinein anfasst: Middleware, die JSON parst, ein Proxy, der neu kodiert, oder ein Framework, das dir ein Objekt statt eines Strings gibt.

Lies den Body einmal als Bytes oder String, prüfe ihn und parse dann genau diesen String. Jede Sprachanleitung zeigt, wo der Raw Body in ihren Frameworks liegt, und die Anleitung für den eigenen Server hat eine Tabelle für die gängigen Node- und Python-Frameworks.

Mit konstanter Laufzeit vergleichen

Ein normaler String-Vergleich stoppt beim ersten abweichenden Zeichen. Theoretisch kann ein Angreifer, der Tausende Requests misst, herausfinden, wie viele führende Zeichen eines Rateversuchs stimmen, und so Zeichen für Zeichen eine gültige Signatur bauen. Ein Vergleich mit konstanter Laufzeit dauert gleich lang, egal wie viel übereinstimmt. Jede Sprache hat einen:

SpracheVergleich mit konstanter Laufzeit
Node.jscrypto.timingSafeEqual(a, b), das bei unterschiedlicher Länge eine Exception wirft, also prüfe zuerst die Länge
Pythonhmac.compare_digest(a, b)
Gohmac.Equal(a, b)
PHPhash_equals($known, $user)
RubyOpenSSL.secure_compare(a, b) oder ActiveSupport::SecurityUtils.secure_compare in Rails

Zeitstempel und Replays

Ein einmal mitgeschnittener gültiger Request bleibt für immer gültig, außer etwas lässt ihn ablaufen. Der signierte Zeitstempel ist dieses Etwas: Lehne jede Zustellung ab, die mehr als 5 Minuten von deiner Uhr abweicht, in beide Richtungen. Weil Ikiform jeden Versuch mit frischem Zeitstempel neu signiert, besteht ein erneuter Versuch auch drei Tage später; nur ein kopierter alter Request scheitert.

Innerhalb dieser 5 Minuten ist ein Replay weiterhin möglich, ebenso ein echtes Duplikat: Ikiform versucht es erneut, wenn es nicht rechtzeitig ein 2xx bekommt, auch wenn dein Handler fertig war. Speichere webhook-id mit einem Unique Constraint und überspringe IDs, die du schon verarbeitet hast. Das schließt beides aus.

Halte die Serveruhr synchron. Eine Uhr, die um mehr als 5 Minuten abweicht, lehnt jede Zustellung mit timestamp_too_old oder timestamp_too_new ab. Verwaltete Plattformen halten Uhren meist synchron; auf deiner eigenen VM lass NTP laufen.

Das Secret rotieren

Rotiere das Secret, wenn jemand geht, der es kannte, wenn es geloggt oder committet worden sein könnte, oder nach Zeitplan. Roll signing secret (Signatur-Secret rotieren) auf der Endpunktseite erzeugt ein neues, und 24 Stunden lang wird jede Zustellung mit beiden signiert:

Was webhook-signature vor, während und nach einer Secret-Rotation enthältvorherv1,<old>RotationneuesSecreteinmalangezeigtnächste24Stundenv1,<old>v1,<new>nachhernur v1,<new>neues deployenWas webhook-signature vor, während und nach einer Secret-Rotation enthältvorherv1,<old>Rotationneues Secret einmal angezeigtnächste 24 Stundenv1,<old> v1,<new>nachhernur v1,<new>neues deployen
  1. Vor der Rotation trägt jede Zustellung eine Signatur, erstellt mit dem alten Secret.
  2. Du wählst Roll signing secret und kopierst das neue Secret.
  3. In den nächsten 24 Stunden trägt jede Zustellung zwei Signaturen, sodass ein Server mit einem der beiden Secrets sie akzeptiert. Deploye das neue Secret in diesem Zeitfenster.
  4. Nach 24 Stunden tragen Zustellungen nur noch die neue Signatur, und ein Server mit dem alten Secret lehnt sie ab.
Dein Handler braucht dafür keinen speziellen Code, solange er jeden v1-Eintrag prüft statt nur den ersten.

Ist nach 24 Stunden noch das alte Secret deployt, schlägt jede Zustellung fehl. Ikiform versucht jede 8-mal über etwa drei Tage erneut. Sobald du das richtige Secret deployt hast, nutze also Retry (Erneut versuchen) im Zustellungsprotokoll für alles, was bereits aufgegeben wurde.

Nach bestandener Prüfung

Eine gültige Signatur beweist, dass der Request von Ikiform kam und nicht verändert wurde. Sie beweist nicht, dass der Inhalt sicher oder neu ist. Drei Gewohnheiten erledigen den Rest:

  • Wiederholungen überspringen. Speichere webhook-id oder die id des Payloads, die denselben Wert hat, mit einem Unique Constraint. Ein erneuter Versuch einer bereits verarbeiteten Zustellung wird so zum No-op.
  • Erst antworten, dann arbeiten. Gib 2xx zurück, sobald das Event gespeichert oder in die Queue gestellt ist. Ikiform zählt alles, was länger als 15 Sekunden dauert, als Fehlschlag und versucht es erneut, was das Duplikatproblem zurückbringt.
  • Felder als nicht vertrauenswürdig behandeln. data.fields enthält, was ein Besucher in ein öffentliches Formular getippt hat. Escape es, bevor du es in HTML, einer Chatnachricht oder einer E-Mail darstellst, und übergib es nie an eine Shell oder unparametrisiert an eine Query.

Testen vor dem Livegang

Ikiform stellt nur an öffentliche https:-URLs auf Port 443 zu, daher kann localhost keinen Webhook empfangen. Starte einen Tunnel wie cloudflared tunnel --url http://localhost:3000 und füge die ausgegebene URL während der Arbeit als zweiten Endpunkt hinzu. Send test (Test senden) auf der Endpunktseite sendet einmal ein endpoint.test-Event, ohne erneute Versuche, signiert genau wie eine echte Zustellung. Lösche den Test-Endpunkt, wenn du fertig bist; er hat sein eigenes Secret, also ändert sich sonst nichts.

Wenn eine Zustellung fehlschlägt und du nicht siehst, warum, kopiere ihre Header und ihren Body aus deinen Logs in den Signatur-Verifier. Er führt dieselbe Prüfung im Browser aus und sagt dir, welcher Schritt fehlgeschlagen ist.

Fünf Arten, wie die Prüfung schiefgeht

FehlerSymptomLösung
Den ganzen whsec_...-String als Schlüssel nutzenno_matching_signature bei jeder ZustellungEntferne whsec_ und dekodiere dann mit base64. Der Schlüssel ist 32 Bytes lang
Einen geparsten Body prüfenno_matching_signature, manchmal nur bei manchen PayloadsPrüfe den Raw Body; parse danach
Nur die erste Signatur prüfenFehlschläge, die direkt nach einer Rotation beginnenIteriere über jeden durch Leerzeichen getrennten v1-Eintrag
Hex statt base64no_matching_signatureStandard Webhooks nutzt Standard-base64 mit Padding, nicht Hex oder base64url
Header mit exakter Schreibweise nachschlagenmissing_headers hinter manchen ProxysHeader-Namen unterscheiden nicht zwischen Groß- und Kleinschreibung. Ikiform sendet sie kleingeschrieben; schlag sie ohne Beachtung der Schreibweise nach

Es gibt keine Ein-Klick-Integrationen. Jedes Tool erreichst du über einen signierten Webhook, direkt oder über ein Automatisierungstool wie Zapier oder n8n. Ein Workspace kann 3 Endpunkte haben, jeder mit eigenem Secret. Den vollständigen Payload, den Retry-Zeitplan und das Zustellungsprotokoll findest du unter Webhooks.

Wähle deinen Fall

Jede Sprachseite hat idiomatische Handler, eine Bibliotheksoption und einen Unit-Test, der den Testvektor von dieser Seite ausführt.

Prüfen, ob es funktioniert

Mach das zuerst auf einem Staging-Endpunkt und dann noch einmal in Produktion. Ikiform hat keinen kostenlosen Tarif. Testen ist kostenlos: Ein Workspace ohne Tarif nimmt 50 Testeinsendungen pro Monat von localhost an. Für den Livegang wähle Pro ab 6 $/Monat für 500 Einsendungen oder zahle einmalig: 10.000 Einsendungen für 49 $ oder 50.000 Einsendungen für 149 $, mit Guthaben, das nie verfällt. Siehe Preise.

  1. Lass den Testvektor durch deinen Code laufen: Er sollte die erwartete Signatur erzeugen, den Header mit zwei Signaturen akzeptieren und den formatierten Body ablehnen.
  2. Öffne im Dashboard Webhooks, füge die öffentliche https:-URL deines Endpunkts hinzu und kopiere das whsec_-Secret in die Umgebung deines Servers.
  3. Wähle Send test auf der Endpunktseite. Das Zustellungsprotokoll zeigt dein 204, und das Event landet in deinen Logs.
  4. Ändere ein Zeichen des Secrets auf deinem Server und sende einen weiteren Test. Das Protokoll zeigt 400 mit no_matching_signature.
  5. Sende das Formular wirklich ab und prüfe, ob die Zustellung von submission.created erfolgreich ist.

Fehlerbehebung

ProblemWas zu tun ist
400 no_matching_signatureFalsches Secret, ein geparster Body oder ein Schlüssel, der nicht base64-dekodiert wurde. Lass den Testvektor durch denselben Codepfad laufen, um zu sehen, was davon.
400 timestamp_too_old oder timestamp_too_newDie Serveruhr geht falsch, oder du spielst einen gespeicherten Request von Hand erneut ab. Synchronisiere die Uhr und nutze Retry im Zustellungsprotokoll, das neu signiert.
400 missing_headersEin Proxy oder Framework hat die webhook-*-Header verworfen, oder der Request kam nicht von Ikiform. Logge jeden Header-Namen, den dein Handler sieht.
Klappt bei Test-Events, scheitert bei echten EinsendungenEchte Payloads enthalten Nicht-ASCII-Zeichen und längeren Text. Dein Code dekodiert oder kodiert den Body vor der Prüfung neu. Prüfe die exakten Bytes.
Jede Zustellung scheitert direkt nach der Secret-RotationDer Handler prüft nur die erste Signatur, oder die 24 Stunden sind mit dem alten Secret im Deployment verstrichen. Prüfe jeden v1-Eintrag und deploye das neue Secret.
Das Zustellungsprotokoll zeigt einen Timeout, aber dein Handler liefIkiform wartet 15 Sekunden. Antworte zuerst, erledige langsame Arbeit danach und überspringe wiederholte webhook-id-Werte.

Fragen

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.