Vérifier les signatures de webhook

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.

Preet SutharFondateur, Ikiform

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.

Un corps de webhook qui passe par un parser JSON global comparé à un parser brut sur la routeexpress.json() global en premierexpress.raw() sur la route du webhookoctetsbrutsce qu'Ikiform asignéJSON.parsedans lemiddlewareJSON.stringifyoctetsdifférents400no_matching_signatureoctetsbrutsce qu'Ikiform asignéBufferintactvérifier,puisanalyser204Un corps de webhook qui passe par un parser JSON global comparé à un parser brut sur la routeexpress.json() global en premierexpress.raw() sur la route du webhookoctets brutsce qu'Ikiform a signéJSON.parsedans le middlewareJSON.stringifyoctets différents400no_matching_signatureoctets brutsce qu'Ikiform a signéBufferintactvérifier, puis analyser204
  1. Avec un parser JSON global enregistré en premier, les octets signés sont analysés en objet dans le middleware.
  2. Resérialiser cet objet donne une chaîne différente, donc la signature ne peut pas correspondre et le gestionnaire répond 400.
  3. Avec un parser brut sur la route du webhook, le gestionnaire reçoit les octets exacts sous forme de Buffer.
  4. Il vérifie ces octets, ne les analyse qu'ensuite et répond 204.
L'ordre compte. Express exécute les middlewares dans l'ordre d'enregistrement, donc la route du webhook passe en premier.

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" }) sur cette seule route donne req.body sous forme de Buffer. Le express.json() global enregistré après ne voit jamais cette requête, car la route a déjà répondu.
  • La limit est au-dessus du plafond de payload de 256 Ko d'Ikiform. La valeur par défaut d'Express, 100 Ko, refuserait une grosse soumission avec 413.
  • verifyWebhook prend les req.headers de Node tels quels ; il recherche les en-têtes sans tenir compte de la casse et vérifie le timestamp, chaque signature v1 et 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

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

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

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 lè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 sur req.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 :

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");
});

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 :

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";
}

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

  1. Lancez node --test. Les quatre cas passent.
  2. Exposez le serveur sur une URL https: publique, par exemple avec cloudflared tunnel --url http://localhost:3000, et ajoutez-la sous Webhooks dans le tableau de bord.
  3. Définissez IKIFORM_WEBHOOK_SECRET avec le secret whsec_ de l'endpoint et redémarrez le serveur.
  4. Choisissez Send test. Le journal de livraison affiche 204.
  5. Déplacez app.use(express.json()) au-dessus de la route du webhook et envoyez un autre test. Il échoue avec 400 no_matching_signature, ce qui prouve que le test détecterait cette erreur. Remettez-le à sa place.

Pièges

ProblèmeCause 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 à stringverifyWebhook prend le corps sous forme de chaîne. Passez req.body.toString("utf8")
Fonctionne en local, échoue sur un hébergeur serverlessLa 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 lengthtimingSafeEqual avec une signature malformée. Vérifiez les longueurs avant de comparer
413 Payload Too Large dans le journal de livraisonLa limite du parser de corps est inférieure à 256 Ko. Augmentez-la sur la route du webhook

Questions

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.