Vérifier les signatures de webhook6 min de lecture
Vérifier les signatures de webhook en Node.js
Vérifiez les signatures des webhooks Ikiform en Node.js : corps bruts dans Express et Fastify, le SDK, le package standardwebhooks et une version node:crypto testée.
Récupérez le corps de la requête en octets bruts avant l'exécution de tout parser JSON, puis passez-le avec les en-têtes et votre secret whsec_ à verifyWebhook de @ikiform/sdk/webhooks. Dans Express, cela signifie express.raw() sur la route du webhook ; dans Fastify, un parser de content-type en chaîne limité à cette route. Sans le SDK, createHmac et timingSafeEqual de node:crypto font la même chose en une vingtaine de lignes.
Cette page couvre les serveurs Node de longue durée : Express et Fastify, plus une version sans dépendance que vous pouvez tester unitairement. Pour les route handlers de Next.js, Astro et d'autres frameworks, consultez la vue d'ensemble ; pour un Cloudflare Worker avec stockage, consultez votre propre serveur.
Ce qui diffère dans Node
Les frameworks Node sont serviables avec le JSON. Enregistrez express.json() ou utilisez les réglages par défaut de Fastify et chaque gestionnaire reçoit req.body sous forme d'objet. Au moment où votre code de webhook s'exécute, les octets signés par Ikiform ont disparu, et JSON.stringify(req.body) en produit d'autres : espacement différent, échappement différent de caractères comme / ou les emoji. La solution porte sur l'endroit où vous récupérez le corps, pas sur la façon de le hacher.
- Avec un parser JSON global enregistré en premier, les octets signés sont analysés en objet dans le middleware.
- Resérialiser cet objet donne une chaîne différente, donc la signature ne peut pas correspondre et le gestionnaire répond 400.
- Avec un parser brut sur la route du webhook, le gestionnaire reçoit les octets exacts sous forme de Buffer.
- Il vérifie ces octets, ne les analyse qu'ensuite et répond 204.
Express
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" })sur cette seule route donnereq.bodysous forme deBuffer. Leexpress.json()global enregistré après ne voit jamais cette requête, car la route a déjà répondu.- La
limitest au-dessus du plafond de payload de 256 Ko d'Ikiform. La valeur par défaut d'Express, 100 Ko, refuserait une grosse soumission avec413. verifyWebhookprend lesreq.headersde Node tels quels ; il recherche les en-têtes sans tenir compte de la casse et vérifie le timestamp, chaque signaturev1et le JSON.- Dans Express 5, une erreur levée depuis un gestionnaire async va au middleware d'erreur, donc la relance est sans risque.
Fastify
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 analyse lui-même application/json. Enregistrer un parser avec parseAs: "string" dans register le remplace uniquement pour les routes de ce plugin, grâce à l'encapsulation, donc le reste de votre API reçoit toujours des objets analysés. request.body est alors la chaîne exacte envoyée par Ikiform.
Avec le package standardwebhooks
Si vous préférez utiliser la bibliothèque de référence de la spécification, npm install standardwebhooks. Sa classe Webhook retire elle-même le préfixe whsec_ et accepte un corps Buffer. Elle est synchrone et lève WebhookVerificationError avec un message :
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 utilise uniquement Web Crypto, donc il nécessite Node 20 ou plus récent. Le package standardwebhooks embarque sa propre implémentation de SHA-256 et fonctionne aussi sur des versions plus anciennes.Avec node:crypto et un test
Aucune dépendance, et une valeur de retour qui indique pourquoi une requête a échoué. Le code hache directement le Buffer brut, donc la question de l'encodage des chaînes ne se pose pas :
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";
}timingSafeEquallève une exception quand les deux buffers ont des longueurs différentes, donc la vérification de longueur vient en premier. Une signature de mauvaise longueur ne correspond pas.createHmac(...).update()appelé deux fois hache le préfixe et le corps comme un seul flux, comme si on les hachait joints.- Node met en minuscules les noms d'en-têtes entrants, donc
headers["webhook-id"]fonctionne directement surreq.headers.
Et un test avec le vecteur de test. Lancez-le avec node --test ; Node 22.18 et plus récent exécutent directement les fichiers TypeScript :
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");
});Le now fixe garde le timestamp de 2026 dans la fenêtre de 5 minutes. Le cas du corps mis en forme est celui qui détecte un parser qui s'immiscerait plus tard, donc gardez-le même quand les autres semblent évidents.
Ignorer les répétitions
Une livraison vérifiée peut tout de même arriver deux fois : si votre gestionnaire termine mais que la réponse est perdue ou prend plus de 15 secondes, Ikiform réessaie avec le même webhook-id. Dans un service Node qui a déjà Redis, un seul SET avec la condition NX mémorise chaque identifiant. Ce code utilise les noms d'options de node-redis 5 et plus récent :
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";
}Appelez await firstDelivery(event.id) après la vérification et avant tout travail ; s'il renvoie false, répondez 204 et arrêtez. L'expiration de 4 jours dépasse la fenêtre de nouvelles tentatives d'Ikiform d'environ trois jours, donc une nouvelle tentative tardive est toujours reconnue. Sans Redis, une colonne unique dans votre base de données fait le même travail. Enregistrer l'identifiant en premier signifie qu'un plantage à mi-chemin ignore aussi la nouvelle tentative ; si vous préférez réessayer, enregistrez-le seulement après la réussite du travail.
Vérifiez que tout fonctionne
- Lancez
node --test. Les quatre cas passent. - Exposez le serveur sur une URL
https:publique, par exemple aveccloudflared tunnel --url http://localhost:3000, et ajoutez-la sous Webhooks dans le tableau de bord. - Définissez
IKIFORM_WEBHOOK_SECRETavec le secretwhsec_de l'endpoint et redémarrez le serveur. - Choisissez Send test. Le journal de livraison affiche
204. - Déplacez
app.use(express.json())au-dessus de la route du webhook et envoyez un autre test. Il échoue avec400 no_matching_signature, ce qui prouve que le test détecterait cette erreur. Remettez-le à sa place.
Pièges
| Problème | Cause et solution |
|---|---|
req.body vaut undefined ou {} | Le type du parser brut ne correspondait pas au Content-Type de la requête, donc il a ignoré le corps. Ikiform envoie application/json |
TypeScript indique qu'un Buffer n'est pas assignable à string | verifyWebhook prend le corps sous forme de chaîne. Passez req.body.toString("utf8") |
| Fonctionne en local, échoue sur un hébergeur serverless | La plateforme a analysé le corps à votre place. Avec le Pages Router de Next.js, définissez bodyParser: false dans la config de la route et lisez le flux vous-même |
RangeError: Input buffers must have the same byte length | timingSafeEqual avec une signature malformée. Vérifiez les longueurs avant de comparer |
413 Payload Too Large dans le journal de livraison | La limite du parser de corps est inférieure à 256 Ko. Augmentez-la sur la route du webhook |
Questions
Ajoutez express.raw({ type: "application/json" }) comme middleware sur la route du webhook, ce qui donne req.body sous forme de Buffer. Enregistrez la route avant tout express.json() global, sinon le parser JSON s'exécute en premier.
Il exige deux buffers de même longueur en octets et lève une RangeError sinon. Comparez d'abord les longueurs et traitez une différence comme une signature invalide.
Oui. Ikiform suit Standard Webhooks, donc new Webhook(secret).verify(body, headers) fonctionne avec le secret whsec_ du tableau de bord. Il lève WebhookVerificationError quand une vérification échoue.
Node 20 et plus récent, ainsi que Cloudflare Workers, Deno et Bun. verifyWebhook utilise uniquement l'API Web Crypto.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.