# 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.

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/webhook-signature-verification/nodejs

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](https://ikiform.com/de/guides/webhook-signature-verification.md#implementation); für einen Cloudflare Worker mit Speicher siehe [deinen eigenen Server](https://ikiform.com/blog/form-submissions-to-your-own-server.md).

## 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.

Diagramm: Ein Webhook-Body, der durch einen globalen JSON-Parser läuft, im Vergleich zu einem Raw-Parser auf der Route

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`:

```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`:

```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`:

```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`:

```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](https://ikiform.com/de/guides/webhook-signature-verification.md#test-vector). Führ ihn mit `node --test` aus; Node 22.18 und neuer führen TypeScript-Dateien direkt aus:

`verify.test.ts`:

```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`:

```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

| Problem | Ursache 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 kann | `verifyWebhook` nimmt den Body als String. Übergib `req.body.toString("utf8")` |
| Klappt lokal, scheitert auf einem Serverless-Host | Die 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 length` | `timingSafeEqual` mit einer fehlerhaften Signatur. Prüfe vor dem Vergleich die Längen |
| `413 Payload Too Large` im Zustellungsprotokoll | Das Limit des Body-Parsers liegt unter 256 KB. Erhöhe es auf der Webhook-Route |

## Fragen

### Wie bekomme ich in Express den Raw Body für einen Webhook?

Füge express.raw({ type: "application/json" }) als Middleware auf der Webhook-Route hinzu, das liefert req.body als Buffer. Registriere die Route vor jedem globalen express.json(), sonst läuft der JSON-Parser zuerst.

### Warum wirft crypto.timingSafeEqual?

Es braucht zwei Buffer mit derselben Bytelänge und wirft sonst einen RangeError. Vergleiche zuerst die Längen und behandle eine Abweichung als fehlgeschlagene Signatur.

### Kann ich das npm-Paket standardwebhooks mit Ikiform nutzen?

Ja. Ikiform folgt Standard Webhooks, daher funktioniert new Webhook(secret).verify(body, headers) mit dem whsec_-Secret aus dem Dashboard. Es wirft WebhookVerificationError, wenn eine Prüfung fehlschlägt.

### Welche Node-Versionen unterstützt @ikiform/sdk für Webhooks?

Node 20 und neuer sowie Cloudflare Workers, Deno und Bun. verifyWebhook nutzt nur die Web Crypto API.

## Weitere Anleitungen

- [Webhook-Signaturen prüfen](https://ikiform.com/de/guides/webhook-signature-verification.md): 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.
- [Python](https://ikiform.com/de/guides/webhook-signature-verification/python.md): Prüfe Webhook-Signaturen von Ikiform in Flask, FastAPI und Django, mit dem Paket standardwebhooks oder der Standardbibliothek, plus einem Test mit pytest.
- [Go net/http](https://ikiform.com/de/guides/webhook-signature-verification/go.md): Verifiziere Ikiform-Webhook-Signaturen in Go mit net/http: Lies den Body einmal, prüf ihn mit crypto/hmac, teste ihn oder nutze die Standard-Webhooks-Bibliothek.
- [Ruby](https://ikiform.com/de/guides/webhook-signature-verification/ruby.md): Verifiziere Ikiform-Webhook-Signaturen in Sinatra mit OpenSSL und einem Minitest-Test und in Rails mit dem Gem standardwebhooks und request.raw_post.
- [PHP](https://ikiform.com/de/guides/webhook-signature-verification/php.md): Verifiziere Ikiform-Webhook-Signaturen in reinem PHP mit hash_hmac und hash_equals und in Laravel mit der Standard-Webhooks-Bibliothek und einer CSRF-Ausnahme.
