12k
All articles

Comment vérifier la signature d'un webhook

Vérifiez les signatures de webhooks Stripe et GitHub avec Express : corps bruts, contrôle HMAC à temps constant, protection contre les rejeux et rotation des secrets.

OpenReplay Team
OpenReplay Team
Comment vérifier la signature d'un webhook

Pour vérifier la signature d’un webhook, calculez un condensé HMAC-SHA256 du corps brut de la requête à l’aide de votre secret partagé. Comparez-le ensuite, en temps constant, à la signature que l’expéditeur a placée dans l’en-tête de la requête, et rejetez la requête si les deux valeurs diffèrent.

Votre gestionnaire tourne en production depuis des semaines. Puis vous ajoutez la vérification de signature, et chaque livraison échoue avec « invalid signature », alors que le secret est indéniablement le bon.

Un endpoint de webhook est une URL publique, et n’importe qui peut y envoyer une requête POST. Sans vérification de signature, votre gestionnaire fait confiance à tout ce qui lui parvient. Si vous souhaitez d’abord vous rafraîchir la mémoire sur ce modèle, le guide OpenReplay consacré aux webhooks en présente les bases. Cet article construit un gestionnaire Express correct pour Stripe et GitHub. Il aborde l’ordre des middlewares pour le corps brut, la comparaison en temps constant, la protection contre le rejeu et la prise en charge de deux secrets lors d’une rotation, en expliquant la raison d’être de chaque étape.

Points clés à retenir

  • La signature d’un webhook est un condensé HMAC-SHA256 des octets exacts de la requête : tout parsing JSON suivi d’une resérialisation avant la vérification la fera échouer.
  • Dans Express, déclarez la route du webhook avec express.raw({ type: 'application/json' }) avant tout app.use(express.json()) global. Le premier parser de corps exécuté consomme le flux de la requête.
  • crypto.timingSafeEqual lève une exception lorsque ses deux buffers ont des longueurs différentes : comparez donc d’abord les longueurs et traitez toute différence comme une signature invalide.
  • Stripe signe ${t}.${rawBody} et peut envoyer une signature v1 par secret actif. GitHub signe uniquement le corps brut, sans horodatage : la déduplication se fait donc sur l’identifiant de livraison.

Fonctionnement de la vérification de signature d’un webhook

La signature d’un webhook est un condensé HMAC que l’expéditeur calcule sur le corps brut de la requête à l’aide d’un secret que seuls vous et lui détenez. Un condensé concordant prouve deux choses : la charge utile n’a pas été modifiée, et elle provient d’une entité qui possède ce secret. HMAC intègre le secret dans un hachage SHA-256 ; sans le secret, personne ne peut donc produire un condensé valide pour un corps donné. Votre serveur refait le calcul et compare les résultats.

Les deux fournisseurs présentés ici utilisent la même primitive, mais signent des chaînes différentes :

StripeGitHub
En-têteStripe-Signature: t=<unix>,v1=<hex>X-Hub-Signature-256: sha256=<hex>
Chaîne signée${t}.${rawBody}corps brut
Encodagehexadécimalhexadécimal
Horodatage signéOuiNon
Protection contre le rejeuRejet des t trop anciensDéduplication sur X-GitHub-Delivery

Stripe documente le format de l’en-tête et la charge utile signée pour une vérification manuelle. Les événements de test comportent également une fausse signature v0 : ignorez donc tous les schémas autres que v1. GitHub décrit son propre schéma dans la page validating webhook deliveries : la valeur commence toujours par sha256=. GitHub envoie toujours l’ancien en-tête SHA-1 X-Hub-Signature, mais uniquement pour préserver la compatibilité des anciennes intégrations.

Lire le corps brut avant tout parser JSON

La cause la plus fréquente d’échec persistant de la vérification de signature est un parser JSON exécuté avant la vérification. La vérification HMAC est exacte à l’octet près : si vous resérialisez l’objet parsé, la moindre différence d’espacement, d’ordre des clés ou d’échappement empêchera le condensé de correspondre.

C’est généralement l’ordre des middlewares qui pose problème. Dans Express, déclarez la route du webhook avec express.raw() avant tout app.use(express.json()) global. Une fois que le parser JSON a consommé le flux de la requête, les octets d’origine sont perdus :

// Broken: the global JSON parser runs first, so req.body is a parsed object
app.use(express.json());
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);

// Fixed: webhook routes first, global JSON parser after
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);
app.use(express.json());

Ajouter express.raw() à la route ne corrige pas la version défectueuse. Dans body-parser, le premier parser exécuté lit le flux et marque la requête comme déjà parsée. Tout parser exécuté ensuite ignore la requête. Le parser brut déclaré au niveau de la route ne reçoit donc rien, et req.body reste l’objet produit par express.json().

Ajoutez une garde Buffer.isBuffer(req.body) dans le gestionnaire. Elle transforme une trompeuse « signature mismatch » en une erreur de configuration évidente. Le code présenté ici cible Express 5. Dans Express 5, req.body vaut undefined lorsque le type de contenu ne correspond pas au parser ; dans Express 4, il valait {}. Dans les deux cas, la garde renvoie une erreur 400.

Si vous ne pouvez pas réordonner vos middlewares, l’option verify de express.json() vous donne accès au Buffer brut avant le parsing, et vous pouvez le stocker sur req pour l’utiliser plus tard. Dans un route handler de l’App Router de Next.js, aucun parser de corps ne s’exécute avant votre code : lisez donc les octets avec Buffer.from(await request.arrayBuffer()), vérifiez-les, et n’appelez JSON.parse qu’ensuite.

Calculer la signature attendue et la comparer en temps constant

Calculez le condensé avec crypto.createHmac('sha256', secret) en lui passant directement le Buffer. Décoder le corps en chaîne puis le réencoder ajoute une étape supplémentaire au cours de laquelle des octets peuvent être altérés. Pour Stripe, enchaînez .update(`${t}.`).update(rawBody) afin que le corps entre intact dans le hachage.

Les signatures de webhook doivent être comparées en temps constant, et non avec ===. Une comparaison de chaînes peut s’interrompre au premier caractère différent : son temps d’exécution révèle alors quelle part de la tentative était correcte. crypto.timingSafeEqual(a, b) s’exécute en un temps identique, quel que soit l’endroit où les buffers diffèrent. Cette fonction lève également une exception lorsque les deux buffers n’ont pas la même longueur. Vérifiez d’abord les longueurs, afin qu’un en-tête mal formé soit traité comme une signature invalide au lieu de faire planter le gestionnaire avec une erreur 500 :

function safeEqualHex(expectedHex, receivedHex) {
  const a = Buffer.from(expectedHex, 'hex');
  const b = Buffer.from(receivedHex, 'hex');
  if (a.length === 0 || a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Lorsqu’une signature échoue systématiquement, vérifiez les points suivants dans cet ordre :

  1. req.body est bien un Buffer (journalisez Buffer.isBuffer(req.body)).
  2. Le secret correspond exactement à cet endpoint et à ce mode. Chaque endpoint Stripe possède son propre secret, et un endpoint utilisé à la fois en mode test et en mode live dispose d’un secret distinct pour chacun.
  3. Si GitHub n’envoie aucun en-tête X-Hub-Signature-256, c’est qu’aucun secret n’est configuré pour le webhook. Vérifiez que vous lisez bien l’en-tête SHA-256 et non l’ancien en-tête SHA-1, comme l’indique la page de dépannage de GitHub.
  4. La chaîne signée est correcte : Stripe exige le préfixe t., et le préfixe sha256= de GitHub doit être retiré.
  5. Les deux valeurs sont en hexadécimal. Collez le corps brut journalisé et votre secret dans le générateur HMAC d’OpenReplay, puis comparez manuellement le condensé obtenu avec celui de l’en-tête.

Bloquer le rejeu grâce à un horodatage ou à un identifiant de livraison

Une signature de webhook valide prouve qui a envoyé une requête, mais pas quand elle a été envoyée : une requête signée interceptée une fois peut donc être rejouée ultérieurement. Avec Stripe, rejetez toute livraison dont l’horodatage signé date de plus de cinq minutes environ, ce qui correspond à la tolérance par défaut des bibliothèques Stripe. Avec GitHub, qui ne signe aucun horodatage, enregistrez chaque identifiant de livraison et ignorez les doublons.

Vérifiez la signature avant l’horodatage. Tant que le HMAC portant sur t n’a pas été vérifié, t n’est qu’un texte auquel un attaquant peut donner n’importe quelle valeur. Traitez également une valeur de t non numérique comme un échec : une comparaison impliquant NaN renvoie false et franchirait silencieusement un contrôle de fraîcheur naïf.

L’en-tête X-GitHub-Delivery de GitHub contient un GUID propre à chaque événement, et une nouvelle livraison conserve le même GUID. N’enregistrez l’identifiant qu’une fois le traitement réussi. GitHub ne relance pas automatiquement les livraisons échouées et considère une livraison comme échouée s’il ne reçoit pas de réponse 2xx dans les 10 secondes. Si vous enregistrez l’identifiant avant un traitement qui échoue, la nouvelle livraison que vous déclencherez manuellement par la suite sera ignorée.

Accepter deux secrets pendant une rotation

Pendant la rotation d’un secret de webhook, acceptez une requête dès que l’un des secrets actuellement valides produit un condensé correspondant à l’une des valeurs v1 de l’en-tête. Lorsque vous renouvelez le secret d’un endpoint Stripe, l’ancien secret peut rester valide pendant une durée de votre choix, jusqu’à 24 heures. Tant qu’il n’a pas expiré, chaque livraison comporte une signature v1 distincte pour chaque secret encore valide.

Un parser d’en-tête Stripe qui réduit l’en-tête à un objet ne conserve qu’une seule valeur v1 et rejette des livraisons pourtant valides : rassemblez donc toutes les valeurs dans un tableau. Pour GitHub, testez chaque secret configuré sur l’unique en-tête. Les boucles some() s’arrêtent dès qu’une correspondance est trouvée, sans pour autant divulguer d’information exploitable : chaque comparaison individuelle reste en temps constant.

Le gestionnaire Express complet

Ce gestionnaire ESM réunit toutes les étapes. Il renvoie 400 pour tout échec de signature ou de format (les expéditeurs ne distinguent que les réponses 2xx des autres) et 200 pour les doublons GitHub, qui sont acquittés puis ignorés :

import express from 'express';
import crypto from 'node:crypto';

const app = express();
const TOLERANCE_SECONDS = 300;

// Current secret first, previous secret during rotation
const STRIPE_SECRETS = [
  process.env.STRIPE_WEBHOOK_SECRET,
  process.env.STRIPE_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);
const GITHUB_SECRETS = [
  process.env.GITHUB_WEBHOOK_SECRET,
  process.env.GITHUB_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);

if (STRIPE_SECRETS.length === 0 || GITHUB_SECRETS.length === 0) {
  throw new Error('Webhook secrets are not configured');
}

function safeEqualHex(expectedHex, receivedHex) {
  const a = Buffer.from(expectedHex, 'hex');
  const b = Buffer.from(receivedHex, 'hex');
  if (a.length === 0 || a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

function parseStripeHeader(header) {
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(',')) {
    const i = part.indexOf('=');
    if (i === -1) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't') timestamp = value;
    else if (key === 'v1') signatures.push(value); // keep every v1
  }
  return { timestamp, signatures };
}

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('Stripe-Signature');
  if (!header || !Buffer.isBuffer(req.body)) {
    return res.status(400).send('Missing signature or raw body');
  }

  const { timestamp, signatures } = parseStripeHeader(header);
  if (!timestamp || signatures.length === 0) {
    return res.status(400).send('Malformed signature header');
  }

  const valid = STRIPE_SECRETS.some((secret) => {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.`)
      .update(req.body)
      .digest('hex');
    return signatures.some((sig) => safeEqualHex(expected, sig));
  });
  if (!valid) return res.status(400).send('Invalid signature');

  // Only trust t after the HMAC over it has been verified
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (!Number.isFinite(age) || Math.abs(age) > TOLERANCE_SECONDS) {
    return res.status(400).send('Timestamp outside tolerance');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Hand event off to a queue; respond fast
  res.sendStatus(200);
});

const seenDeliveries = new Set(); // Use a shared store with a TTL in production

app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-Hub-Signature-256');
  if (!header?.startsWith('sha256=') || !Buffer.isBuffer(req.body)) {
    return res.status(400).send('Missing signature or raw body');
  }
  const received = header.slice('sha256='.length);

  const valid = GITHUB_SECRETS.some((secret) =>
    safeEqualHex(
      crypto.createHmac('sha256', secret).update(req.body).digest('hex'),
      received,
    ),
  );
  if (!valid) return res.status(400).send('Invalid signature');

  const deliveryId = req.get('X-GitHub-Delivery');
  if (!deliveryId) return res.status(400).send('Missing delivery ID');
  if (seenDeliveries.has(deliveryId)) return res.sendStatus(200);

  const payload = JSON.parse(req.body.toString('utf8'));
  // Process payload, then record the ID only after success
  seenDeliveries.add(deliveryId);
  res.sendStatus(200);
});

app.use(express.json()); // Everything else, after the webhook routes

app.listen(3000);

Chaque garde répond à un cas d’échec précis. Un en-tête manquant renvoie une erreur 400 au lieu de lever une exception. Toutes les valeurs v1 sont conservées. Les longueurs sont vérifiées avant l’appel à timingSafeEqual. Un corps qui n’est pas un Buffer est détecté comme une erreur de configuration. Pour les applications qui n’utilisent que Stripe, la méthode stripe.webhooks.constructEvent de la bibliothèque officielle effectue ces mêmes vérifications à votre place ; la documentation Stripe sur les webhooks explique comment l’appeler.

Conclusion

La vérification de signature d’un webhook se résume à hacher les octets exacts reçus et à comparer le résultat de manière sûre. Le reste du gestionnaire sert à préserver l’intégrité de ces octets, à bloquer le rejeu et à survivre à la rotation des secrets. Commencez par placer vos routes de webhook avant express.json(), puis ajoutez le gestionnaire ci-dessus. Si vous hésitez encore sur l’adéquation d’un mécanisme de push à votre intégration, l’article webhooks vs. polling compare les avantages et inconvénients de chaque approche.

FAQ

Pourquoi la vérification de signature Stripe échoue-t-elle lors des tests en local avec la Stripe CLI ?

La Stripe CLI utilise son propre secret de signature, distinct de celui de tout endpoint créé dans le Dashboard Stripe. Tous deux commencent par whsec_, ce qui rend la confusion facile. Lorsque vous exécutez stripe listen, la CLI affiche son secret dans le terminal. Utilisez cette valeur pour les événements relayés par la CLI, et ne vérifiez jamais ces événements avec le secret d'un endpoint du Dashboard, ni l'inverse.

Faut-il utiliser constructEvent de Stripe ou vérifier la signature manuellement ?

Utilisez stripe.webhooks.constructEvent si Stripe est votre unique fournisseur. Vous lui transmettez le corps brut de la requête, la valeur de l'en-tête Stripe-Signature et le secret de votre endpoint, et la méthode lève une erreur si la signature n'est pas valide. Par défaut, les bibliothèques Stripe rejettent tout horodatage signé qui s'écarte de plus de 5 minutes de l'heure actuelle, et un argument facultatif permet de définir une autre fenêtre. La vérification manuelle se justifie lorsque GitHub ou d'autres fournisseurs partagent le même chemin de code.

Pourquoi utiliser HMAC plutôt que de hacher le secret et le corps ensemble avec SHA-256 ?

HMAC résiste aux attaques par extension de longueur, qui compromettent une construction naïve telle que le SHA-256 du secret suivi du corps. SHA-256 repose sur la construction de Merkle-Damgård : quiconque détient un condensé valide peut ajouter des données au message et calculer un condensé valide pour le message allongé, sans connaître le secret. HMAC hache la clé en deux passes imbriquées, ce qui empêche cette attaque. Stripe et GitHub signent tous deux avec HMAC-SHA256.

HTTPS reste-t-il nécessaire si je vérifie les signatures des webhooks ?

Oui. Une signature HMAC garantit l'intégrité et l'authenticité, mais pas la confidentialité. Sans TLS, n'importe qui sur le chemin réseau peut lire la charge utile, qui peut contenir des données client ou de paiement, et intercepter une requête signée pour la rejouer plus tard. La vérification de signature, la protection contre le rejeu et HTTPS couvrent chacun une menace différente : un endpoint de webhook en production a besoin des trois.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.