ITSkillsCenter
Business Digital

Webhooks paiement sécurisés : signature HMAC et protection contre le rejeu

14 دقائق للقراءة

📍 Lecture connexe : Stripe, Paystack, Flutterwave et Wave en 2026 : intégrer un processeur de paiement — pour la vue d’ensemble du paysage paiement.

Un endpoint webhook accessible publiquement sur Internet sans vérification de signature est l’équivalent d’un endpoint qui croit n’importe quel inconnu. Si le webhook concerne un paiement, l’attaquant peut forger un événement « charge réussie » et obtenir un service sans débourser un kobo. Pourtant cette erreur reste l’une des plus fréquentes en production. Ce tutoriel installe les défenses de base contre les trois attaques classiques (forge, rejeu, ré-ordonnancement) et fournit une implémentation de référence en TypeScript et Python que l’on peut adapter à n’importe quel processeur de paiement.

Prérequis

  • Node.js 22 LTS ou Python 3.12+, selon le langage qu’on suit
  • PostgreSQL 16+ pour la table de déduplication d’événements
  • Un compte test sur au moins un PSP pour tester (Stripe / Paystack / Flutterwave / Wave)
  • Niveau attendu : intermédiaire — connaissance de HMAC et de bases SQL
  • Temps estimé : environ 90 minutes

Étape 1 — Comprendre les trois attaques contre un webhook

L’attaque la plus simple est la forge. L’attaquant scanne Internet à la recherche d’endpoints qui ressemblent à des webhooks (URL contenant /webhook, /callback, /notify) et envoie des payloads JSON simulant un événement de succès. Sans signature, l’application traite l’événement comme légitime. Conséquence : service livré sans paiement.

L’attaque rejeu est plus subtile. L’attaquant intercepte un webhook légitime (par exemple via un proxy mal configuré ou un log exposé) et le rejoue plus tard contre le même endpoint. Sans contrôle de fraîcheur, l’application traite à nouveau l’événement comme nouveau. Conséquence : double facturation, double remboursement, ou double livraison selon l’événement.

L’attaque par ré-ordonnancement exploite la concurrence. Deux événements arrivent en même temps (par exemple charge.success et charge.refunded pour la même transaction si elle a été remboursée immédiatement) et l’application les traite dans le mauvais ordre. Conséquence : état incohérent en base, le client reçoit le service alors que la transaction est déjà annulée.

Les trois défenses correspondantes sont la signature HMAC, le contrôle de timestamp avec fenêtre, et la table d’événements avec contrainte UNIQUE plus traitement ordonné. On les implémente dans cet ordre.

Étape 2 — Vérifier la signature HMAC

Tous les PSP modernes signent leurs webhooks. Le mécanisme général est le même : un secret partagé entre le PSP et le commerçant, un calcul HMAC sur le body brut, et la signature transmise dans un header. Les variations portent sur l’algorithme (SHA-256 chez Stripe et Wave, SHA-512 chez Paystack, hash arbitraire chez Flutterwave) et sur le format du header.

// src/webhook-verify.ts
import crypto from 'node:crypto'

export function verifyHmac(
  body: Buffer,
  receivedSig: string,
  secret: string,
  algo: 'sha256' | 'sha512' = 'sha256',
): boolean {
  const expected = crypto.createHmac(algo, secret).update(body).digest('hex')
  if (receivedSig.length !== expected.length) return false
  return crypto.timingSafeEqual(
    Buffer.from(receivedSig, 'hex'),
    Buffer.from(expected, 'hex'),
  )
}

Trois précautions techniques. Le HMAC est calculé sur les bytes bruts du body, pas sur l’objet JSON parsé : le moindre changement de formatage (espaces, ordre des clés) invaliderait la signature. C’est pourquoi on utilise express.raw côté Node ou la lecture brute du request.body côté Django. La fonction crypto.timingSafeEqual compare les buffers en temps constant, indépendamment du nombre de caractères qui matchent ; c’est ce qui empêche les attaques par mesure de temps qui pourraient deviner la signature octet par octet. Et on vérifie d’abord la longueur car timingSafeEqual lève une exception si les buffers sont de taille différente.

L’usage diffère légèrement selon le PSP. Pour Stripe, on utilise plutôt l’helper officiel stripe.webhooks.constructEvent qui gère tout. Pour Paystack, on calcule le HMAC SHA-512 et on compare avec le header x-paystack-signature. Pour Wave, on parse le format t=TIMESTAMP,v1=SIG avant de signer le payload concaténé. Pour Flutterwave, le mécanisme est différent : on compare un secret hash arbitraire transmis dans verif-hash.

Étape 3 — Contrôler le timestamp

La signature seule ne protège pas contre le rejeu : un webhook légitime capturé reste valide indéfiniment puisque sa signature ne change pas. Pour bloquer le rejeu, on inclut un timestamp dans le payload signé et on rejette les événements dont le timestamp s’écarte trop de l’instant courant.

export function verifyTimestamp(
  ts: number,
  toleranceSec = 300,
): boolean {
  const now = Math.floor(Date.now() / 1000)
  return Math.abs(now - ts) <= toleranceSec
}

La fenêtre de 300 secondes est un compromis. Plus large, on accepte des dérives d’horloge inhabituelles (NTP en panne, machine virtuelle décalée) mais on laisse plus de temps à un attaquant pour rejouer. Plus stricte, on bloque les attaques mais on rejette aussi des webhooks légitimes lors de pics de retards réseau. Les valeurs typiques en production sont 5 minutes pour Stripe et Wave, 15 minutes pour les PSP plus tolérants.

Stripe et Wave incluent le timestamp dans le header de signature, ce qui rend le contrôle direct. Paystack et Flutterwave ne le font pas natif ; on s’appuie alors sur le timestamp inclus dans le payload de l’événement (data.created_at par exemple) pour vérifier la fraîcheur, en gardant à l’esprit que ce timestamp n’est pas signé séparément.

Étape 4 — Construire la table de déduplication

La défense contre le rejeu et le double traitement repose sur une table d’événements webhook avec contrainte UNIQUE sur l’identifiant PSP de l’événement. Avant tout traitement, on tente d’insérer l’événement ; si l’insertion échoue (clé dupliquée), on sait que l’événement a déjà été traité et on retourne 200 sans rien faire.

-- migration : create webhook_events table
CREATE TABLE webhook_events (
  id BIGSERIAL PRIMARY KEY,
  psp VARCHAR(20) NOT NULL,
  event_id VARCHAR(128) NOT NULL,
  event_type VARCHAR(64) NOT NULL,
  payload JSONB NOT NULL,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  processed_at TIMESTAMPTZ,
  status VARCHAR(20) NOT NULL DEFAULT 'received',
  CONSTRAINT uniq_psp_event UNIQUE (psp, event_id)
);

CREATE INDEX idx_webhook_status ON webhook_events (status, received_at);

La contrainte UNIQUE composite (psp, event_id) permet de gérer plusieurs PSP avec leurs propres conventions d’identifiant sans collision. Le champ status évolue : received à l’arrivée, processing pendant le traitement, processed après succès, failed après échec (auquel cas un job de relance peut le reprendre).

L’index sur (status, received_at) sert au job de relance qui balaie périodiquement les événements en failed ou bloqués en processing depuis trop longtemps.

Étape 5 — Composer le handler complet

On assemble les trois couches dans un handler générique réutilisable.

// src/webhook-handler.ts
import { Pool } from 'pg'
const pool = new Pool() // configuré via env vars

export async function handleWebhook(
  psp: string,
  eventId: string,
  eventType: string,
  payload: object,
  process: () => Promise<void>,
): Promise<'ok' | 'duplicate' | 'failed'> {
  // 1. Tenter l'insertion (garantit l'unicité)
  const insert = await pool.query(
    `INSERT INTO webhook_events (psp, event_id, event_type, payload)
     VALUES ($1, $2, $3, $4)
     ON CONFLICT (psp, event_id) DO NOTHING
     RETURNING id`,
    [psp, eventId, eventType, payload],
  )

  if (insert.rowCount === 0) return 'duplicate'

  const rowId = insert.rows[0].id
  // 2. Marquer en cours
  await pool.query(
    `UPDATE webhook_events SET status = 'processing' WHERE id = $1`,
    [rowId],
  )

  try {
    await process()
    await pool.query(
      `UPDATE webhook_events SET status = 'processed', processed_at = now() WHERE id = $1`,
      [rowId],
    )
    return 'ok'
  } catch (e) {
    await pool.query(
      `UPDATE webhook_events SET status = 'failed' WHERE id = $1`,
      [rowId],
    )
    return 'failed'
  }
}

L’idiome INSERT … ON CONFLICT DO NOTHING RETURNING est ce qui rend l’opération atomique. Si deux instances du serveur reçoivent le même webhook simultanément (cas du load balancer qui bascule), une seule des deux pourra insérer ; l’autre verra rowCount = 0 et retournera duplicate. C’est strictement supérieur à un check SELECT suivi d’un INSERT, qui exposerait à une race condition.

Étape 6 — Intégrer le handler dans la route Express

app.post(
  '/webhooks/paystack',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const sig = req.headers['x-paystack-signature'] as string
    if (!verifyHmac(req.body, sig, process.env.PAYSTACK_SECRET!, 'sha512')) {
      return res.status(401).send('bad sig')
    }
    const event = JSON.parse(req.body.toString())
    const result = await handleWebhook(
      'paystack',
      event.data.reference, // ID unique chez Paystack
      event.event,
      event,
      async () => {
        // Logique métier ici : enregistrer la charge, mettre à jour la commande
      },
    )
    res.sendStatus(200) // toujours 200 même sur duplicate ou failed
  },
)

Le retour 200 systématique est crucial. Si on retournait 4xx ou 5xx sur un duplicate, le PSP retenterait le webhook indéfiniment, polluant les logs et faussant les métriques. La règle est : signature et timestamp invalides = 401 ; tout le reste = 200, avec un log interne pour les cas anormaux.

Pour une logique métier complexe (notification email, mise à jour de plusieurs tables, appel à des services externes), on découpe en deux temps : le handler webhook accuse réception et publie un job sur une file (BullMQ, NATS, Redis Streams), un worker dédié consomme le job et exécute la logique. Cela garantit le respect du timeout PSP même si le traitement métier est lent.

Étape 7 — Tester les attaques

On vérifie que les trois défenses fonctionnent par des tests dédiés. Test forge : envoyer une requête sans signature ou avec une signature aléatoire ; le handler doit retourner 401. Test rejeu : capturer un webhook légitime, attendre 6 minutes, le rejouer ; le handler doit retourner 401 sur le timestamp expiré (PSP qui inclut le timestamp signé) ou 200 avec status duplicate (autres PSP). Test concurrence : envoyer le même webhook deux fois simultanément ; un seul des deux doit déclencher la logique métier.

# Test forge avec curl
curl -X POST http://localhost:3000/webhooks/paystack \
  -H 'Content-Type: application/json' \
  -d '{"event":"charge.success","data":{"amount":1000000}}'
# Attendu : 401 Unauthorized

# Test duplicate (envoi double avec signature valide)
SIG=... # signature calculée hors-test
curl -X POST http://localhost:3000/webhooks/paystack \
  -H "x-paystack-signature: $SIG" -d "$BODY"
curl -X POST http://localhost:3000/webhooks/paystack \
  -H "x-paystack-signature: $SIG" -d "$BODY"
# Attendu : deux 200, mais une seule entrée dans webhook_events

Étape 8 — Monitorer les anomalies

L’observabilité dédiée aux webhooks remonte trois métriques. Le taux de signatures invalides : si ce taux monte au-dessus de 1 % du volume total, soit un attaquant scanne, soit la configuration de signature côté PSP a divergé. Le délai webhook-traitement : différence entre received_at et processed_at ; doit rester sous 5 secondes au 95e percentile. Le ratio duplicates : taux normal autour de 1 à 3 % (les PSP retentent légitimement) ; au-delà, soit on subit du rejeu, soit le PSP a un bug.

On expose ces métriques en Prometheus avec trois compteurs et un histogramme. Les alertes correspondantes : 401 rate > 5 % sur 10 minutes (probable attaque), processing latency p95 > 10 secondes (saturation), duplicate rate > 10 % (anomalie PSP).

Étape 9 — Rotation des secrets webhook

Les secrets webhook doivent être rotés périodiquement (tous les 6 à 12 mois selon la politique de sécurité). Le défi est de basculer sans perdre d’événements en transit. La technique standard est le dual-secret : pendant la fenêtre de bascule, on accepte la signature avec l’ancien OU le nouveau secret, on déploie le nouveau secret côté PSP, on attend une période d’observation (24h), puis on ne valide plus qu’avec le nouveau.

function verifyHmacDual(body: Buffer, sig: string, secrets: string[]): boolean {
  return secrets.some(s => verifyHmac(body, sig, s, 'sha512'))
}

Cette procédure évite l’erreur classique du « j’ai changé la clé et soudain plus aucun webhook ne passe ». La fenêtre dual-secret coûte un peu plus de CPU (deux HMAC au lieu d’un) mais c’est négligeable face à la sécurité opérationnelle qu’elle apporte.

Étape 10 — Tester en environnement adversarial

Une bonne défense webhook ne se vérifie pas par la lecture du code mais par des tests adversariaux. On construit une suite de tests automatisés qui essaie de contourner chaque protection.

Premier test, payload modifié post-signature : on génère un webhook valide, on modifie un champ du body après calcul de la signature, et on l’envoie ; le handler doit retourner 401. Deuxième test, signature copiée d’un autre événement : on prend une signature valide d’un événement et on l’utilise sur un payload différent ; doit retourner 401. Troisième test, timestamp futur lointain : on signe avec un timestamp fixé à 2030 ; doit retourner 401 si le timestamp est dans la signature, ou 200 + duplicate selon le PSP. Quatrième test, cas des null bytes : on insère un null byte dans le payload pour tester si le parser tronque ; doit être traité comme un payload invalide sans crash.

Ces tests vivent dans la suite d’intégration et sont exécutés à chaque pull request qui touche aux modules de webhook. Le coût d’écriture est faible (une centaine de lignes au total) et la garantie qu’on ne régresse pas une protection critique a une valeur considérable.

Étape 11 — Politique de logs et confidentialité

Les logs d’un endpoint webhook sont une mine d’informations sensibles : emails clients, montants, IDs de transactions. Une politique de logging stricte applique trois règles. Premièrement, on ne log jamais le payload complet : seulement quelques champs structurés (event_id, type, status, partial customer email). Deuxièmement, on ne log jamais les signatures ni les secrets, même partiellement. Troisièmement, on appose un TTL aux logs de webhook (typiquement 90 jours) pour limiter la fenêtre d’exposition en cas de fuite des logs.

Pour les payloads complets dont on a besoin (debug, audit), on les chiffre avec une clé dédiée et on les stocke dans une table à accès restreint. Cette séparation entre logs opérationnels (accessibles à l’équipe dev/ops) et payloads chiffrés (accessibles uniquement à un compte d’audit) reflète le principe de moindre privilège.

Erreurs fréquentes

Erreur Cause Solution
Signature invalide alors que le secret est correct Body parsé en JSON avant la vérification HMAC Lire le body brut (raw bytes) avant tout parsing
Comparaison timing-vulnérable Usage de === ou == sur la signature Toujours crypto.timingSafeEqual ou hmac.compare_digest
Webhook traité plusieurs fois Pas de table de déduplication Contrainte UNIQUE sur (psp, event_id) avec ON CONFLICT DO NOTHING
Rejet d’événements légitimes Fenêtre timestamp trop stricte (1 minute) 5 à 15 minutes selon la tolérance acceptable
Logs qui contiennent les payloads complets Pas de masquage des données sensibles Logger un sous-ensemble : event_id, type, status, jamais le payload complet

Étape 12 — Cas particuliers Stripe Connect

Sur une marketplace Stripe Connect, les webhooks sont un peu plus subtils. La plateforme reçoit des événements à deux niveaux : ceux concernant le compte plateforme lui-même (paiements directs, refunds plateforme), et ceux concernant les comptes connectés (transactions des vendeurs). La documentation Stripe distingue les endpoints connect qui captent les événements des comptes connectés des endpoints standard.

La signature des webhooks Connect est calculée avec un secret distinct du secret webhook plateforme. La même fonction de vérification HMAC s’applique mais avec ce secret spécifique. Une erreur classique est de mélanger les deux secrets : on configure un endpoint Connect mais on vérifie avec le secret du webhook plateforme, et tous les événements sont rejetés comme invalides. La table de mapping endpoint/secret doit être explicite et documentée.

L’identifiant du compte connecté concerné est passé dans le champ account du payload de l’événement. La logique métier doit l’extraire et router vers le bon utilisateur de la plateforme. Cette indirection ajoute une étape mais elle est indispensable pour gérer correctement la marketplace.

Ressources

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité