Un webhook de paiement arrive sur votre serveur en affirmant qu'une commande a été payée. Lui faites-vous confiance ? Si vous créditez la commande uniquement sur la base de l'appel HTTP — sans vérifier une signature cryptographique — vous venez de donner aux attaquants un moyen gratuit de marquer n'importe quelle commande comme payée.
Ce qui peut mal tourner sans vérification
Un point de terminaison de webhook non protégé est facilement exploitable. Un attaquant n'a besoin que de connaître votre URL de rappel — qui est souvent découvrable à partir de l'onglet réseau d'un navigateur, d'un dépôt GitHub public ou d'un test de paiement — et il peut POSTer une charge utile conçue :
POST /webhook HTTP/1.1
Content-Type: application/json
{"order_id": "1337", "status": "paid", "amount": "99.99"}Si votre gestionnaire lit $_POST['status'] === 'paid' et crédite la commande, l'attaquant vient d'acheter pour 99,99 $ de biens gratuitement. Ce n'est pas une attaque théorique ; cela se produit régulièrement contre des intégrations de paiement non protégées.
Comment fonctionne la signature HMAC-SHA256
HMAC (Code d'authentification de message basé sur un hachage) avec SHA-256 résout ce problème en rendant la charge utile infalsifiable sans la clé secrète. Le processus est :
- La passerelle de paiement connaît votre api_secret — un secret partagé uniquement entre vous et la passerelle.
- Avant d'envoyer le webhook, elle calcule :
HMAC-SHA256(timestamp + "." + body, api_secret) - Elle inclut cette signature et le timestamp dans les en-têtes HTTP.
- Votre serveur recalcule le même HMAC et compare. S'ils correspondent, la charge utile est authentique.
Un attaquant qui ne connaît pas votre api_secret ne peut pas falsifier une signature valide — SHA-256 est une fonction unidirectionnelle. Le timestamp empêche les attaques par rejeu : même un webhook intercepté valide devient inutile après 5 minutes.
Implémentation de la vérification : PHP
function verifyPayoraWebhook(string $secret): array {
$ts = $_SERVER['HTTP_X_PAYORA_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_PAYORA_SIGNATURE'] ?? '';
$body = file_get_contents('php://input');
// 1. rejeter les timestamps obsolètes (protection contre le rejeu)
if (!ctype_digit($ts) || abs(time() - (int)$ts) > 300) {
http_response_code(400); exit('timestamp obsolète');
}
// 2. recalculer et comparer — hash_equals() empêche les attaques par timing
$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expected, $sig)) {
http_response_code(401); exit('signature invalide');
}
return json_decode($body, true);
}
$event = verifyPayoraWebhook('sk_your_secret');
if ($event['status'] === 'paid') {
creditOrder($event['order_id']);
}Implémentation de la vérification : Node.js
import crypto from 'node:crypto';
function verifyPayoraWebhook(req, rawBody, secret) {
const ts = req.headers['x-payora-timestamp'] ?? '';
const sig = req.headers['x-payora-signature'] ?? '';
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return null; // obsolète
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex');
// timingSafeEqual empêche les attaques par canal latéral de timing
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
? JSON.parse(rawBody)
: null;
}Erreurs courantes qui cassent la vérification
1. Lire un corps analysé au lieu des octets bruts. Le middleware json() d'Express re-serialise le JSON, ce qui peut réorganiser les clés ou changer les espaces. Toujours tamponner le corps brut de la requête avant l'analyse, et utiliser cela pour le HMAC.
2. Utiliser === au lieu d'une comparaison sécurisée par timing. Une comparaison de chaîne naïve divulgue des informations de timing qui peuvent être exploitées pour deviner la signature octet par octet. Utilisez hash_equals() en PHP ou crypto.timingSafeEqual() en Node.
3. Sauter la vérification du timestamp. Un webhook valide mais ancien capturé par un observateur de réseau pourrait être rejoué. La fenêtre de 5 minutes (±300 secondes) élimine ce risque tout en gérant un dérive d'horloge raisonnable.
4. Journaliser le corps brut avant la vérification. Si votre pipeline de journalisation est compromis, les charges utiles enregistrées pourraient être rejouées. Vérifiez d'abord, journalisez après.
Au-delà de la signature : Idempotence
La vérification prouve que le webhook est authentique ; l'idempotence garantit que vous ne créditez pas une commande deux fois. Les réseaux réessaient les webhooks sur des réponses non-200, donc votre gestionnaire peut recevoir le même événement plusieurs fois. La solution consiste à enregistrer un webhook_id dans votre base de données et à sauter le traitement si vous l'avez déjà vu :
// pseudo-code
$id = $event['webhook_id'];
if (DB::exists('processed_webhooks', $id)) return respond(200);
DB::insert('processed_webhooks', $id);
creditOrder($event['order_id']);Ensemble, la vérification de la signature et l'idempotence vous offrent une intégration de paiement à la fois sécurisée et résiliente. Voir le guide d'intégration complet pour la référence complète des webhooks de Payora.
Comments