Aller au contenu
Tutorial

Sécurité des webhooks : signatures HMAC, idempotence et protection contre la relecture

| 9 min read

Trois modèles de code empêchent les charges utiles usurpées, les livraisons en double et les demandes de webhooks rejouées. Exemples Node.js avec HMAC-SHA256 et vérifications d'horodatage.

Digital encryption padlock on a circuit board
Photo by Adi Goldstein on Unsplash

Votre gestionnaire de webhook accepte une requête POST, analyse le JSON et exécute la logique métier. Ça marche jusqu'à ce que quelqu'un envoie une charge utile falsifiée à votre point de terminaison. Ou jusqu'à ce que le fournisseur réessaye une livraison et que votre gestionnaire facture un client deux fois. Ou jusqu'à ce qu'un attaquant enregistre une demande légitime et la rejoue six heures plus tard.

Trois protections résolvent ce problème : la vérification de la signature HMAC, les clés d'idempotence et le rejet de la relecture basé sur l'horodatage. Ce didacticiel couvre chacun d'entre eux avec le code Node.js fonctionnel.

Comment fonctionne la signature de webhooks

Tous les principaux fournisseurs de webhooks (Stripe, GitHub, Shopify, Twilio) signent les charges utiles sortantes avec HMAC-SHA256. Le Le fournisseur combine le corps brut de la requête avec un secret partagé, calcule un hachage et envoie ce hachage dans un en-tête. Votre serveur recalcule le hachage avec le même secret et compare. Si les hachages correspondent, la charge utile est authentique.

La formule de signature ressemble à ceci :

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

Stripe envoie la signature Stripe-Signature. GitHub l'envoie X-Hub-Signature-256. L'algorithme est le même ; seuls le nom et le format de l'en-tête diffèrent.

Vérifier une signature de webhook Stripe dans Node.js

Les rayures Stripe-Signature l'en-tête contient un horodatage (t=) et un ou plusieurs versions signatures (v1=). Voici comment le vérifier sans le SDK Stripe :

const crypto = require('crypto');

function verifyStripeSignature(payload, header, secret) {
  const parts = header.split(',');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const signature = parts.find(p => p.startsWith('v1=')).slice(3);

  const signedPayload = timestamp + '.' + payload;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  if (expected !== signature) {
    throw new Error('Invalid signature');
  }

  // Replay protection: reject if older than 5 minutes
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (age > 300) {
    throw new Error('Timestamp too old');
  }

  return JSON.parse(payload);
}

Détails clés : Stripe concatène l'horodatage et le corps brut avec un séparateur de points. Le v1 la signature utilise HMAC-SHA256. La vérification de l'horodatage à la fin offre une protection contre la relecture (expliquée en détail ci-dessous).

Vérifier une signature de webhook GitHub dans Node.js

Le format de GitHub est plus simple. Le X-Hub-Signature-256 l'en-tête contient sha256= suivi par l'hexagone HMAC du corps brut :

const crypto = require('crypto');

function verifyGitHubSignature(payload, signatureHeader, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  const trusted = Buffer.from(expected, 'utf8');
  const untrusted = Buffer.from(signatureHeader, 'utf8');

  if (trusted.length !== untrusted.length) {
    throw new Error('Invalid signature');
  }

  if (!crypto.timingSafeEqual(trusted, untrusted)) {
    throw new Error('Invalid signature');
  }

  return JSON.parse(payload);
}

Avis crypto.timingSafeEqual. Une norme === la comparaison fuit des informations de synchronisation. Un attaquant peut mesurer les temps de réponse pour deviner la bonne signature octet par octet. Comparaison à temps constant élimine ce vecteur.

Calculez les signatures HMAC avec l'API de hachage de Botoi

Si vous devez calculer ou vérifier les signatures HMAC-SHA256 à partir d'un script, d'un pipeline CI ou d'une fonction sans serveur sans importer crypto, utilisez le /v1/hash/hmac point final :

curl -X POST https://api.botoi.com/v1/hash/hmac \\
  -H "Content-Type: application/json" \\
  -d '{
    "text": "1712345678.{\\"event\\":\\"payment_intent.succeeded\\",\\"amount\\":4999}",
    "key": "whsec_your_stripe_secret",
    "algorithm": "sha256"
  }'

Réponse:

{
  "success": true,
  "data": {
    "hmac": "a3f2b8c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0",
    "algorithm": "sha256"
  }
}

Cela renvoie le HMAC codé en hexadécimal. Comparez-le avec l'en-tête de signature du fournisseur de webhook. Le point de terminaison prend en charge les deux sha256 et sha512 algorithmes.

Créer un middleware Express pour la vérification HMAC

Enveloppez la vérification de signature dans un middleware réutilisable afin que chaque itinéraire de webhook soit protégé sans dupliquer le code :

const crypto = require('crypto');

function webhookAuth(secret) {
  return (req, res, next) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];

    if (!signature || !timestamp) {
      return res.status(401).json({ error: 'Missing signature headers' });
    }

    // Step 1: Replay protection
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
    if (age > 300) {
      return res.status(401).json({ error: 'Request too old' });
    }

    // Step 2: HMAC verification
    const payload = JSON.stringify(req.body);
    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + '.' + payload)
      .digest('hex');

    const trusted = Buffer.from(expected, 'utf8');
    const untrusted = Buffer.from(signature, 'utf8');

    if (trusted.length !== untrusted.length ||
        !crypto.timingSafeEqual(trusted, untrusted)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    next();
  };
}

// Mount on your webhook route
app.post('/webhooks/stripe', webhookAuth(process.env.STRIPE_WEBHOOK_SECRET), handler);

Ce middleware rejette les requêtes comportant des en-têtes manquants, des horodatages obsolètes ou des signatures invalides avant votre le gestionnaire s'exécute. La fenêtre de 300 secondes correspond à la tolérance par défaut de Stripe.

Idempotence : évitez les livraisons en double

Les fournisseurs de webhooks réessayent les livraisons ayant échoué. Si votre serveur a renvoyé un 500 après le traitement de l'événement mais avant en envoyant la réponse 200, le fournisseur envoie à nouveau le même événement. Sans idempotence, votre gestionnaire exécute le effets secondaires deux fois : frais doubles, e-mails en double, mises à jour répétées des stocks.

Le correctif : stockez chaque ID d'événement traité et ignorez les événements que vous avez vus auparavant.

Idempotence en mémoire (développement)

const processedEvents = new Map(); // Use Redis in production

async function handleWebhook(req, res) {
  const eventId = req.body.id;

  // Check if already processed
  if (processedEvents.has(eventId)) {
    return res.status(200).json({ status: 'duplicate', eventId });
  }

  // Mark as processing before side effects
  processedEvents.set(eventId, Date.now());

  try {
    await processEvent(req.body);
    return res.status(200).json({ status: 'processed', eventId });
  } catch (err) {
    // Remove on failure so retries work
    processedEvents.delete(eventId);
    return res.status(500).json({ error: 'Processing failed' });
  }
}

La Map œuvre pour le développement local. En production, utilisez un magasin partagé pour que toutes les instances de serveur voir le même ensemble d’événements traités.

Idempotence soutenue par Redis (production)

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

async function handleWebhook(req, res) {
  const eventId = req.body.id;
  const lockKey = \`webhook:processed:\${eventId}\

SET NX est atomique : un seul processus remporte le verrou. Le TTL de 72 heures (259200 secondes) élague automatiquement les anciennes entrées. Si le traitement échoue, le gestionnaire supprime la clé afin que la prochaine tentative puisse réussir.

Protection contre la relecture : rejeter les demandes obsolètes

La vérification HMAC confirme que la charge utile provient du fournisseur. Mais une demande valide signée capturée par un l'attaquant du réseau reste valide pour toujours, sauf si vous ajoutez un contrôle temporel. La protection contre la relecture rejette les charges utiles avec des horodatages antérieurs à un seuil.

function rejectStaleRequests(req, res, next) {
  const timestamp = parseInt(req.headers['x-webhook-timestamp'], 10);

  if (isNaN(timestamp)) {
    return res.status(401).json({ error: 'Missing timestamp' });
  }

  const now = Math.floor(Date.now() / 1000);
  const age = now - timestamp;

  // Reject anything older than 5 minutes or from the future
  if (age > 300 || age < -30) {
    return res.status(401).json({ error: 'Timestamp out of range' });
  }

  next();
}

La tolérance future de 30 secondes gère les dérives d'horloge mineures entre votre serveur et le fournisseur. N'importe quoi plus de 5 minutes ou plus de 30 secondes dans le futur sont rejetées.

Combinez les trois en un seul middleware

Voici le middleware complet qui exécute les trois vérifications en séquence : horodatage, HMAC, puis idempotence.

const crypto = require('crypto');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

function secureWebhook(secret) {
  return async (req, res, next) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];
    const eventId = req.body?.id;

    // 1. Reject missing headers
    if (!signature || !timestamp || !eventId) {
      return res.status(401).json({ error: 'Missing required headers or event ID' });
    }

    // 2. Replay protection: reject stale requests
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
    if (age > 300 || age < -30) {
      return res.status(401).json({ error: 'Timestamp out of range' });
    }

    // 3. HMAC verification
    const payload = JSON.stringify(req.body);
    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + '.' + payload)
      .digest('hex');

    const trusted = Buffer.from(expected, 'utf8');
    const untrusted = Buffer.from(signature, 'utf8');

    if (trusted.length !== untrusted.length ||
        !crypto.timingSafeEqual(trusted, untrusted)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // 4. Idempotency: skip duplicates
    const lockKey = \`webhook:processed:\${eventId}\

L’ordre compte. L'horodatage est la vérification la moins chère (pas d'E/S), donc elle s'exécute en premier. La vérification HMAC est Lié au processeur mais toujours rapide. L'idempotence nécessite un appel Redis, elle s'exécute donc en dernier. Cette séquence rejette le plus de demandes au moindre coût.

Testez votre gestionnaire de webhook avec la boîte de réception des webhooks de Botoi

Utiliser les boutons /v1/webhook/inbox pour créer une URL jetable, calculez une signature HMAC avec /v1/hash/hmacet envoyez une charge utile de test signée. Pas de tunnels, pas de serveur local.

# 1. Create a webhook inbox
INBOX=\$(curl -s -X POST https://api.botoi.com/v1/webhook/inbox/create)
INBOX_ID=\$(echo \$INBOX | jq -r '.data.inbox_id')
INBOX_URL=\$(echo \$INBOX | jq -r '.data.url')
echo "Inbox URL: \$INBOX_URL"

# 2. Compute an HMAC signature for your test payload
PAYLOAD='{"id":"evt_001","event":"order.created","amount":2999}'
TIMESTAMP=\$(date +%s)
SIGNED_PAYLOAD="\$TIMESTAMP.\$PAYLOAD"

HMAC=\$(curl -s -X POST https://api.botoi.com/v1/hash/hmac \\
  -H "Content-Type: application/json" \\
  -d "{
    \\"text\\": \\"\$SIGNED_PAYLOAD\\",
    \\"key\\": \\"test_secret_key\\",
    \\"algorithm\\": \\"sha256\\"
  }" | jq -r '.data.hmac')
echo "HMAC: \$HMAC"

# 3. Send the signed payload to your inbox
curl -s -X POST "\$INBOX_URL" \\
  -H "Content-Type: application/json" \\
  -H "X-Webhook-Signature: \$HMAC" \\
  -H "X-Webhook-Timestamp: \$TIMESTAMP" \\
  -d "\$PAYLOAD"

# 4. Retrieve and inspect the payload
curl -s -X POST "https://api.botoi.com/v1/webhook/inbox/\$INBOX_ID/list" | jq '.data.payloads'

Le point de terminaison de la liste renvoie toutes les charges utiles reçues :

[
  {
    "id": "p_abc123",
    "received_at": "2026-04-05T14:30:00Z",
    "body": {
      "id": "evt_001",
      "event": "order.created",
      "amount": 2999
    }
  }
]

Ce workflow vous permet de tester le flux de signature complet à partir de la ligne de commande. Échangez l'URL de la boîte de réception pour votre point de terminaison de production pour exécuter des tests d’intégration dans CI.

Comment Stripe, GitHub et Shopify signent les webhooks

Fournisseuse En-tête Algorithme Format de charge utile signé Horodatage inclus
Bande Stripe-Signature HMAC-SHA256 t=UNIX.v1=HEX Oui
GitHub X-Hub-Signature-256 HMAC-SHA256 sha256=HEX Non
Shopify X-Shopify-Hmac-Sha256 HMAC-SHA256 Codé en base64 Non
Twilio X-Twilio-Signature HMAC-SHA1 URL + paramètres triés Non

Stripe est le seul à regrouper un horodatage avec la signature, vous offrant ainsi une protection contre la relecture prête à l'emploi. Pour GitHub et Shopify, ajoutez votre propre en-tête d'horodatage ou vérifiez l'heure de création de l'événement à partir du corps de la charge utile.

Liste de contrôle avant de passer en production

  • Stockez le secret du webhook dans une variable d'environnement, pas dans le code source.
  • Analysez le corps brut de la requête, pas la version analysée par JSON. HMAC signe les octets exacts sur le fil.
  • Utiliser crypto.timingSafeEqual pour la comparaison des signatures. Ne jamais utiliser ===.
  • Renvoyez 200 avant d’exécuter un traitement lent. Mettez le travail en file d’attente et accusez réception de la livraison.
  • Enregistrez les demandes rejetées (signature invalide, horodatage obsolète, doublon) pour le débogage et les alertes.
  • Définissez un TTL sur votre magasin d'idempotence. 72 heures couvrent la plupart des fenêtres de nouvelle tentative du fournisseur.
  • Testez avec une boîte de réception jetable et des signatures HMAC calculées avant de connecter un fournisseur en direct.

FAQ

Pourquoi utiliser HMAC-SHA256 au lieu d'un secret partagé dans un paramètre de requête ?
Un paramètre de requête circule en texte brut via les proxys et les journaux d'accès. HMAC-SHA256 signe l'intégralité du corps de la requête avec le secret, de sorte qu'un attaquant qui intercepte l'URL ne peut toujours pas falsifier une signature valide.
Combien de temps dois-je conserver les ID d’événement traités pour l’idempotence ?
Conservez-les au moins 24 à 72 heures. La plupart des fournisseurs de webhooks réessayent dans cette fenêtre. Après 72 heures, vous pouvez supprimer en toute sécurité les anciens identifiants de votre boutique.
Quelle tolérance d’horodatage dois-je utiliser pour la protection contre la relecture ?
Cinq minutes (300 secondes) est la norme. Stripe utilise 300 secondes. Des délais plus courts risquent de rejeter les livraisons légitimes retardées par la congestion du réseau.
Puis-je utiliser le point de terminaison botoi hash/hmac pour vérifier les webhooks entrants ?
Oui. POSTEZ le corps de la charge utile et votre secret dans /v1/hash/hmac avec l'algorithme sha256. Comparez le HMAC renvoyé avec l'en-tête de signature du fournisseur de webhook.
Ai-je besoin des trois protections ou puis-je en choisir une ?
La vérification HMAC est le minimum. Ajoutez de l'idempotence si votre gestionnaire de webhook déclenche des effets secondaires tels que des frais ou des e-mails. Ajoutez une protection contre la relecture si votre système gère des transactions financières ou des événements sensibles en matière de sécurité.

Commencez a construire avec botoi

150+ endpoints API pour la recherche, le traitement de texte, la generation d'images et les utilitaires pour developpeurs. Offre gratuite, sans carte bancaire.