Skip to Content
WebhooksSignaturverifizierung

Signaturverifizierung

Prüfe Webhook-Signaturen immer, bevor du den Payload verarbeitest. So stellst du sicher, dass die Anfrage wirklich von Ignite stammt und nicht manipuliert wurde.

Warum Signaturen prüfen?

Webhook-Endpoints sind öffentlich erreichbare URLs. Ohne Signaturprüfung könnte jeder gefälschte Ereignisse an deinen Endpoint senden. Die Signaturverifizierung sichert:

  • Authentizität — Die Anfrage kommt von Ignite
  • Integrität — Der Payload wurde unterwegs nicht verändert

Signaturformat

Jede Webhook-Anfrage enthält einen Header X-Webhook-Signature in folgendem Format:

t=1705316400000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
KomponenteBeschreibung
tUnix-Zeitstempel in Millisekunden, wann die Signatur erzeugt wurde
v1HMAC-SHA256-Signatur (hex-kodiert)

Verifizierungsalgorithmus

Die Signatur wird über eine Kombination aus Zeitstempel und Anfragebody berechnet. Es gibt zwei Ansätze:

Option A: Raw Body (empfohlen)

signed_payload = timestamp + "." + raw_body signature = HMAC-SHA256(signed_payload, signing_secret)

Option B: JSON.stringify

signed_payload = timestamp + "." + JSON.stringify(body) signature = HMAC-SHA256(signed_payload, signing_secret)

Dabei ist raw_body die exakten Bytes aus der Anfrage, und JSON.stringify(body) serialisiert das geparste JSON-Objekt erneut.

Verifizierungsansätze

Es gibt zwei Wege, die Signatur zu prüfen. Wähle den, der zu deinem Fall passt:

AnsatzZuverlässigkeitKomplexitätWann nutzen
Raw BodygarantierthöherProduktionssysteme, hohe Zuverlässigkeitsanforderungen
JSON.stringifysehr hochniedrigerSchnelle Integrationen, wenn Raw-Body-Zugriff schwierig ist

Warum der Unterschied?

Die Signatur bezieht sich auf die exakten Bytes, die Ignite sendet. Wenn du JSON.stringify() auf den geparsten Body anwendest:

  • Funktioniert meist: JSON.stringify() ist für dasselbe JavaScript-Objekt deterministisch
  • Mögliche Probleme: Schlüsselreihenfolge in verschachtelten Objekten, Zahlenformatierung (1.0 vs. 1) oder Unicode-Escaping könnten theoretisch zwischen Serialisierern abweichen

In der Praxis ist JSON.stringify() zuverlässig, weil Ignite und dein Code Standard-JSON-Serialisierung nutzen. Der Raw Body gibt dir jedoch eine 100-%-Garantie.


Option A: Raw Body verwenden (empfohlen)

Dieser Ansatz nutzt die exakten empfangenen Bytes, sodass die Signatur immer passt.

import { createHmac, timingSafeEqual } from "crypto"; import { NextApiRequest, NextApiResponse } from "next"; // Disable the default body parser to access raw body export const config = { api: { bodyParser: false, }, }; async function getRawBody(req: NextApiRequest): Promise<string> { const chunks: Buffer[] = []; for await (const chunk of req) { chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); } return Buffer.concat(chunks).toString('utf-8'); } function isValidSignature(rawBody: string, signatureHeader: string | string[] | undefined, secret: string): boolean { try { if (typeof signatureHeader !== "string") { return false; } const time = signatureHeader.match(/t=(\d+)/)?.[1]; const token = signatureHeader.match(/v1=(\w+)/)?.[1]; if (!time || !token) { return false; } const signedPayload = `${time}.${rawBody}`; const hmac = createHmac("sha256", secret); const calculatedSignature = hmac.update(signedPayload).digest("hex"); return timingSafeEqual(Buffer.from(token), Buffer.from(calculatedSignature)); } catch { return false; } } export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== "POST") { return res.status(405).end(); } const rawBody = await getRawBody(req); if (!isValidSignature(rawBody, req.headers["x-webhook-signature"], process.env.WEBHOOK_SIGNING_SECRET!)) { return res.status(403).end(); } const body = JSON.parse(rawBody); // Process the webhook... return res.status(200).json({ received: true }); }

Option B: JSON.stringify (einfacher)

Dieser Ansatz serialisiert den geparsten Body erneut. Er ist einfacher, setzt aber auf konsistente JSON-Serialisierung zwischen Ignite und deinem Code.

In der Praxis funktioniert das zuverlässig, weil beide Seiten Standard-JSON.stringify() ohne Formatierungsoptionen nutzen. Nutze das, wenn dir Raw-Body-Zugriff unpraktisch ist oder du schnell integrieren willst.

import { createHmac, timingSafeEqual } from "crypto"; import { NextApiRequest, NextApiResponse } from "next"; // Using the default body parser (no config export needed) function isValidSignature(req: NextApiRequest, secret: string): boolean { try { const signature = req.headers["x-webhook-signature"]; if (typeof signature !== "string") { return false; } const time = signature.match(/t=(\d+)/)?.[1]; const token = signature.match(/v1=(\w+)/)?.[1]; if (!time || !token) { return false; } const signedPayload = `${time}.${JSON.stringify(req.body)}`; const hmac = createHmac("sha256", secret); const calculatedSignature = hmac.update(signedPayload).digest("hex"); return timingSafeEqual(Buffer.from(token), Buffer.from(calculatedSignature)); } catch { return false; } } export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== "POST") { return res.status(405).end(); } if (!isValidSignature(req, process.env.WEBHOOK_SIGNING_SECRET!)) { return res.status(403).end(); } const body = req.body; // Process the webhook... return res.status(200).json({ received: true }); }

Zeitstempelprüfung (optional)

Zum Schutz vor Replay-Angriffen kannst du zusätzlich prüfen, ob der Zeitstempel aktuell ist:

function isTimestampValid(signatureHeader: string, toleranceMs = 300000): boolean { const time = signatureHeader.match(/t=(\d+)/)?.[1]; if (!time) return false; const timestamp = parseInt(time, 10); const now = Date.now(); // Reject if timestamp is more than 5 minutes old (default) return Math.abs(now - timestamp) <= toleranceMs; }

Fehlerbehebung

Signatur stimmt nicht

Wenn die Signaturprüfung fehlschlägt, prüfe Folgendes:

  1. Richtiges Secret — Verwende das Signing Secret aus dem Webhook-Abonnement, keinen API-Token
  2. Vollständiger Header — Lies den kompletten Header X-Webhook-Signature ein, inklusive t= und v1=
  3. Body unverändert — Bei Option B darf kein Middleware den Body vor der Prüfung verändern

Debugging

Mit Logging findest du Signaturprobleme leichter:

function isValidSignature(rawBody, signatureHeader, secret) { const time = signatureHeader.match(/t=(\d+)/)?.[1]; const token = signatureHeader.match(/v1=(\w+)/)?.[1]; const signedPayload = `${time}.${rawBody}`; const calculatedSignature = crypto.createHmac("sha256", secret) .update(signedPayload) .digest("hex"); console.log('Received signature:', token); console.log('Calculated signature:', calculatedSignature); console.log('Payload length:', rawBody.length); console.log('First 100 chars:', rawBody.substring(0, 100)); return token === calculatedSignature; }