Webhook-Signaturen prüfen

Webhook-Signaturen prüfen6 Min. Lesezeit

Webhook-Signaturen in Node.js prüfen

Prüfe Webhook-Signaturen von Ikiform in Node.js: Raw Bodys in Express und Fastify, das SDK, das Paket standardwebhooks und eine getestete Version mit node:crypto.

Preet SutharGründer, Ikiform

Hol dir den Request-Body als rohe Bytes, bevor ein JSON-Parser läuft, und übergib ihn dann mit den Headern und deinem whsec_-Secret an verifyWebhook aus @ikiform/sdk/webhooks. In Express heißt das express.raw() auf der Webhook-Route; in Fastify ein String-Content-Type-Parser, der auf diese Route beschränkt ist. Ohne SDK erledigen createHmac und timingSafeEqual aus node:crypto dasselbe in etwa 20 Zeilen.

Diese Seite behandelt langlebige Node-Server: Express und Fastify sowie eine Version ohne Abhängigkeiten, die du per Unit-Test prüfen kannst. Für Route Handler in Next.js, Astro und anderen Frameworks siehe die Übersicht; für einen Cloudflare Worker mit Speicher siehe deinen eigenen Server.

Was in Node anders ist

Node-Frameworks sind bei JSON hilfsbereit. Registriere express.json() oder nutze die Standardeinstellungen von Fastify, und jeder Handler bekommt req.body als Objekt. Wenn dein Webhook-Code läuft, sind die Bytes, die Ikiform signiert hat, weg, und JSON.stringify(req.body) erzeugt andere: andere Leerzeichen, anderes Escaping von Zeichen wie / oder Emojis. Die Lösung betrifft, wo du den Body herbekommst, nicht wie du ihn hashst.

Ein Webhook-Body, der durch einen globalen JSON-Parser läuft, im Vergleich zu einem Raw-Parser auf der Routeglobales express.json() zuerstexpress.raw() auf der Webhook-Routerohe Byteswas Ikiformsigniert hatJSON.parsein derMiddlewareJSON.stringifyandere Bytes400no_matching_signaturerohe Byteswas Ikiformsigniert hatBufferunverändertprüfen, dannparsen204Ein Webhook-Body, der durch einen globalen JSON-Parser läuft, im Vergleich zu einem Raw-Parser auf der Routeglobales express.json() zuerstexpress.raw() auf der Webhook-Routerohe Byteswas Ikiform signiert hatJSON.parsein der MiddlewareJSON.stringifyandere Bytes400no_matching_signaturerohe Byteswas Ikiform signiert hatBufferunverändertprüfen, dann parsen204
  1. Ist zuerst ein globaler JSON-Parser registriert, werden die signierten Bytes in der Middleware zu einem Objekt geparst.
  2. Dieses Objekt erneut zu serialisieren ergibt einen anderen String, daher kann die Signatur nicht passen, und der Handler antwortet mit 400.
  3. Mit einem Raw-Parser auf der Webhook-Route erhält der Handler die exakten Bytes als Buffer.
  4. Er prüft diese Bytes, parst sie erst danach und antwortet mit 204.
Die Reihenfolge zählt. Express führt Middleware in der Reihenfolge der Registrierung aus, also kommt die Webhook-Route zuerst.

Express

server.js
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

const app = express();

app.post("/webhooks/ikiform", express.raw({ type: "application/json", limit: "300kb" }), async (req, res) => {
  try {
    const event = await verifyWebhook({
      payload: req.body.toString("utf8"),
      headers: req.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET,
    });
    if (event.type === "submission.created") {
      console.info("submission", event.data.submission_id, event.data.fields);
    }
    res.sendStatus(204);
  } catch (error) {
    if (error instanceof WebhookVerificationError) return res.status(400).send(error.reason);
    throw error;
  }
});

app.use(express.json());

app.listen(3000);
  • express.raw({ type: "application/json" }) nur auf dieser Route liefert req.body als Buffer. Das danach registrierte globale express.json() sieht diesen Request nie, weil die Route schon geantwortet hat.
  • Das limit liegt über Ikiforms Payload-Obergrenze von 256 KB. Der Standardwert von Express, 100 KB, würde eine große Einsendung mit 413 ablehnen.
  • verifyWebhook nimmt Nodes req.headers unverändert; es schlägt Header ohne Beachtung der Schreibweise nach und prüft den Zeitstempel, jede v1-Signatur und das JSON.
  • In Express 5 landet ein in einem async-Handler geworfener Fehler in der Error-Middleware, daher ist das erneute Werfen sicher.

Fastify

server.js
import Fastify from "fastify";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

const app = Fastify({ logger: true });

app.register(async (webhooks) => {
  webhooks.addContentTypeParser(
    "application/json",
    { parseAs: "string", bodyLimit: 300 * 1024 },
    (_request, body, done) => done(null, body),
  );

  webhooks.post("/webhooks/ikiform", async (request, reply) => {
    try {
      const event = await verifyWebhook({
        payload: request.body,
        headers: request.headers,
        secret: process.env.IKIFORM_WEBHOOK_SECRET,
      });
      request.log.info({ type: event.type, id: event.id }, "ikiform webhook");
      return reply.code(204).send();
    } catch (error) {
      if (error instanceof WebhookVerificationError) return reply.code(400).send(error.reason);
      throw error;
    }
  });
});

await app.listen({ port: 3000, host: "0.0.0.0" });

Fastify parst application/json selbst. Ein Parser mit parseAs: "string", registriert innerhalb von register, ersetzt ihn dank Kapselung nur für Routen in diesem Plugin, sodass der Rest deiner API weiterhin geparste Objekte bekommt. request.body ist dann genau der String, den Ikiform gesendet hat.

Mit dem Paket standardwebhooks

Wenn du lieber die Referenzbibliothek der Spezifikation nutzt: npm install standardwebhooks. Ihre Klasse Webhook entfernt das Präfix whsec_ selbst und akzeptiert einen Buffer als Body. Sie ist synchron und wirft WebhookVerificationError mit einer Meldung:

server.js
import express from "express";
import { Webhook, WebhookVerificationError } from "standardwebhooks";

const webhook = new Webhook(process.env.IKIFORM_WEBHOOK_SECRET);
const app = express();

app.post("/webhooks/ikiform", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = webhook.verify(req.body, req.headers);
    console.info(event.type, event.id);
    res.sendStatus(204);
  } catch (error) {
    if (error instanceof WebhookVerificationError) return res.status(400).send(error.message);
    throw error;
  }
});
@ikiform/sdk nutzt nur Web Crypto und braucht daher Node 20 oder neuer. Das Paket standardwebhooks bringt sein eigenes SHA-256 mit und läuft auch auf älteren Versionen.

Mit node:crypto und einem Test

Keine Abhängigkeit, und ein Rückgabewert, der sagt, warum ein Request gescheitert ist. Er hasht den rohen Buffer direkt, also stellt sich die Frage nach der String-Kodierung gar nicht:

verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export type VerifyFailure =
  | "missing_headers"
  | "invalid_timestamp"
  | "timestamp_too_old"
  | "timestamp_too_new"
  | "no_matching_signature";

type Headers = Record<string, string | string[] | undefined>;

const TOLERANCE_SECONDS = 5 * 60;

function header(headers: Headers, name: string) {
  const value = headers[name];
  return Array.isArray(value) ? value.join(" ") : value;
}

export function verifySignature(
  rawBody: Buffer | string,
  headers: Headers,
  secret: string,
  now = Date.now(),
): VerifyFailure | null {
  const id = header(headers, "webhook-id");
  const timestamp = header(headers, "webhook-timestamp");
  const signatures = header(headers, "webhook-signature");
  if (!id || !timestamp || !signatures) return "missing_headers";
  if (!/^\d{1,12}$/.test(timestamp)) return "invalid_timestamp";

  const age = Math.floor(now / 1000) - Number(timestamp);
  if (age > TOLERANCE_SECONDS) return "timestamp_too_old";
  if (age < -TOLERANCE_SECONDS) return "timestamp_too_new";

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest();

  for (const entry of signatures.split(" ")) {
    const [version, value] = entry.split(",", 2);
    if (version !== "v1" || !value) continue;
    const received = Buffer.from(value, "base64");
    if (received.length === expected.length && timingSafeEqual(received, expected)) return null;
  }
  return "no_matching_signature";
}
  • timingSafeEqual wirft, wenn die beiden Buffer unterschiedlich lang sind, daher kommt die Längenprüfung zuerst. Eine Signatur mit falscher Länge ist kein Treffer.
  • Zweimal createHmac(...).update() hasht Präfix und Body als einen Stream, genau wie wenn du sie verbunden hashst.
  • Node schreibt eingehende Header-Namen klein, daher funktioniert headers["webhook-id"] direkt auf req.headers.

Und ein Test mit dem Testvektor. Führ ihn mit node --test aus; Node 22.18 und neuer führen TypeScript-Dateien direkt aus:

verify.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { verifySignature } from "./verify.ts";

const secret = "whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=";
const rolled = "whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A=";
const 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."}}';
const headers = {
  "webhook-id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "webhook-timestamp": "1792300000",
  "webhook-signature": "v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=",
};
const now = 1792300000 * 1000 + 30_000;

test("accepts the test vector", () => {
  assert.equal(verifySignature(Buffer.from(body), headers, secret, now), null);
});

test("accepts either secret during a roll", () => {
  const both = { ...headers, "webhook-signature": `${headers["webhook-signature"]} v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=` };
  assert.equal(verifySignature(body, both, secret, now), null);
  assert.equal(verifySignature(body, both, rolled, now), null);
});

test("rejects a re-serialized body", () => {
  const pretty = JSON.stringify(JSON.parse(body), null, 2);
  assert.equal(verifySignature(pretty, headers, secret, now), "no_matching_signature");
});

test("rejects a stale timestamp", () => {
  assert.equal(verifySignature(body, headers, secret, now + 10 * 60_000), "timestamp_too_old");
});

Das feste now hält den Zeitstempel von 2026 im 5-Minuten-Fenster. Der formatierte Fall ist der, der einen später eingeschlichenen Parser erwischt, also behalte ihn, auch wenn die anderen offensichtlich wirken.

Wiederholungen überspringen

Eine geprüfte Zustellung kann trotzdem zweimal ankommen: Wenn dein Handler fertig wird, die Antwort aber verloren geht oder länger als 15 Sekunden dauert, versucht Ikiform es mit derselben webhook-id erneut. In einem Node-Service, der schon Redis hat, merkt sich ein SET mit der Bedingung NX jede ID. Das hier nutzt die Optionsnamen von node-redis 5 und neuer:

deliveries.js
import { createClient } from "redis";

const redis = await createClient({ url: process.env.REDIS_URL }).connect();

export async function firstDelivery(deliveryId) {
  const stored = await redis.set(`ikiform:delivery:${deliveryId}`, "1", {
    condition: "NX",
    expiration: { type: "EX", value: 4 * 24 * 60 * 60 },
  });
  return stored === "OK";
}

Ruf await firstDelivery(event.id) nach der Prüfung und vor jeder Arbeit auf; gibt es false zurück, antworte mit 204 und hör auf. Der Ablauf nach 4 Tagen überdauert Ikiforms Retry-Fenster von etwa drei Tagen, sodass auch ein später erneuter Versuch erkannt wird. Ohne Redis erledigt eine Unique-Spalte in deiner Datenbank denselben Job. Wenn du die ID zuerst speicherst, wird bei einem Absturz mittendrin auch der erneute Versuch übersprungen; willst du lieber einen erneuten Versuch, speichere sie erst, nachdem die Arbeit gelungen ist.

Prüfen, ob es funktioniert

  1. Führ node --test aus. Alle vier Fälle bestehen.
  2. Mach den Server unter einer öffentlichen https:-URL erreichbar, zum Beispiel mit cloudflared tunnel --url http://localhost:3000, und füge sie im Dashboard unter Webhooks hinzu.
  3. Setze IKIFORM_WEBHOOK_SECRET auf das whsec_-Secret des Endpunkts und starte den Server neu.
  4. Wähle Send test. Das Zustellungsprotokoll zeigt 204.
  5. Verschiebe app.use(express.json()) über die Webhook-Route und sende einen weiteren Test. Er scheitert mit 400 no_matching_signature, was beweist, dass der Test diesen Fehler erwischen würde. Verschieb es wieder zurück.

Stolperfallen

ProblemUrsache und Lösung
req.body ist undefined oder {}Der type des Raw-Parsers passte nicht zum Content-Type des Requests, also hat er den Body übersprungen. Ikiform sendet application/json
TypeScript meldet, dass ein Buffer nicht string zugewiesen werden kannverifyWebhook nimmt den Body als String. Übergib req.body.toString("utf8")
Klappt lokal, scheitert auf einem Serverless-HostDie Plattform hat den Body für dich geparst. Im Pages Router von Next.js setze bodyParser: false in der config der Route und lies den Stream selbst
RangeError: Input buffers must have the same byte lengthtimingSafeEqual mit einer fehlerhaften Signatur. Prüfe vor dem Vergleich die Längen
413 Payload Too Large im ZustellungsprotokollDas Limit des Body-Parsers liegt unter 256 KB. Erhöhe es auf der Webhook-Route

Fragen

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