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| Komponente | Beschreibung |
|---|---|
t | Unix-Zeitstempel in Millisekunden, wann die Signatur erzeugt wurde |
v1 | HMAC-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:
| Ansatz | Zuverlässigkeit | Komplexität | Wann nutzen |
|---|---|---|---|
| Raw Body | garantiert | höher | Produktionssysteme, hohe Zuverlässigkeitsanforderungen |
| JSON.stringify | sehr hoch | niedriger | Schnelle 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.0vs.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.
Next.js API Routes
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.
Next.js API Routes
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:
- Richtiges Secret — Verwende das Signing Secret aus dem Webhook-Abonnement, keinen API-Token
- Vollständiger Header — Lies den kompletten Header
X-Webhook-Signatureein, inklusivet=undv1= - 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;
}