vendredi 25 septembre 2026

À propos

Contact

Sécurité

Sécuriser les webhooks entrants : signature HMAC et rejeu

Un point de terminaison qui reçoit un webhook Stripe ou GitHub sans vérifier sa signature accepte n'importe quelle requête forgée. Voici comment le blinder correctement.

Par Clément Hadrot • 16 août 2023 • 5 min de lecture • Aucun commentaire
Sécuriser les webhooks entrants : signature HMAC et rejeu

Un client vend des formations en ligne et débloque l’accès à ses contenus dès qu’un paiement Stripe est confirmé, via un webhook qui arrive sur une route REST personnalisée de son WordPress. Le code initial, écrit rapidement, se contentait de lire le corps de la requête et de créer l’accès correspondant. Aucune vérification de signature. N’importe qui connaissant l’URL du point de terminaison — visible dans le code JavaScript côté client dans ce cas précis — pouvait forger une requête et débloquer un accès gratuitement.

Les webhooks, qu’ils viennent de Stripe, de GitHub ou d’un service maison, partagent tous le même principe de sécurisation : une signature calculée côté émetteur avec un secret partagé, à vérifier côté récepteur avant de faire confiance au contenu. Voici comment l’implémenter correctement dans une route REST WordPress.

Comprendre ce que prouve réellement une signature HMAC

Un HMAC (Hash-based Message Authentication Code) est une empreinte cryptographique calculée à partir du corps de la requête et d’une clé secrète connue uniquement de l’émetteur et du récepteur. Recalculer cette empreinte côté serveur et la comparer à celle envoyée dans un en-tête permet de vérifier deux choses à la fois : que le contenu n’a pas été modifié en chemin, et que l’expéditeur connaît bien le secret partagé, donc qu’il s’agit du service attendu et non d’un tiers qui a deviné l’URL.

Stripe envoie par exemple un en-tête Stripe-Signature contenant un horodatage et une signature calculée sur horodatage.corps_de_la_requête. GitHub procède de façon similaire avec l’en-tête X-Hub-Signature-256, calculé directement sur le corps brut avec l’algorithme SHA-256.

Enregistrer une route REST qui lit le corps brut

L'essentiel à retenir : Une signature HMAC prouve l'origine, pas seulement l'intégrité ; hash_equals évite les fuites par timing ; Un horodatage rejette les requêtes rejouées

Premier piège fréquent : WordPress décode automatiquement le JSON reçu sur une route REST, ce qui modifie potentiellement le corps par rapport à ce qui a servi au calcul de la signature côté émetteur (ordre des clés, espaces). Il faut donc récupérer le corps brut, pas la version déjà interprétée.

add_action( 'rest_api_init', function () {
    register_rest_route( 'mon-site/v1', '/webhook', array(
        'methods'             => 'POST',
        'callback'            => 'mon_site_handle_webhook',
        'permission_callback' => '__return_true',
    ) );
} );

function mon_site_handle_webhook( WP_REST_Request $request ) {
    $payload   = $request->get_body();
    $signature = $request->get_header( 'x-webhook-signature' );
    $secret    = getenv( 'WEBHOOK_SECRET' );

    if ( ! mon_site_verifier_signature( $payload, $signature, $secret ) ) {
        return new WP_Error( 'invalid_signature', 'Signature invalide', array( 'status' => 401 ) );
    }

    // Traitement du webhook une fois la signature validée.
    return new WP_REST_Response( array( 'received' => true ), 200 );
}

Le permission_callback renvoie volontairement __return_true ici : la route doit rester accessible sans authentification WordPress classique, puisque l’émetteur du webhook n’est pas un utilisateur du site. C’est la vérification de signature, juste après, qui fait office de contrôle d’accès.

Calculer et comparer la signature en toute sécurité

La fonction de vérification elle-même utilise hash_hmac() pour recalculer la signature attendue, puis hash_equals() pour la comparer — jamais l’opérateur === ni une simple comparaison de chaînes.

function mon_site_verifier_signature( $payload, $signature_recue, $secret ) {
    if ( empty( $signature_recue ) || empty( $secret ) ) {
        return false;
    }

    $signature_attendue = hash_hmac( 'sha256', $payload, $secret );

    return hash_equals( $signature_attendue, $signature_recue );
}

La raison de hash_equals() plutôt qu’une comparaison classique tient à la résistance aux attaques par mesure de temps : une comparaison naïve de chaînes s’arrête au premier caractère différent, ce qui permet en théorie de deviner la signature octet par octet en mesurant le temps de réponse du serveur. hash_equals() compare toujours l’intégralité des deux chaînes en temps constant, rendant cette attaque inopérante.

Se protéger du rejeu d’une requête interceptée

Vérifier la signature ne suffit pas complètement : si un attaquant intercepte une requête légitime (par exemple via un proxy compromis ou un log exposé), il peut la rejouer telle quelle, avec une signature toujours valide puisque le contenu n’a pas changé. C’est pourquoi les webhooks sérieux incluent un horodatage dans la signature, comme le fait Stripe.

  • Extrayez l’horodatage transmis dans l’en-tête de signature.
  • Rejetez la requête si l’horodatage s’écarte de plus de quelques minutes de l’heure actuelle du serveur.
  • Conservez temporairement (quelques heures suffisent) les identifiants d’événements déjà traités pour rejeter un doublon exact, quand le service fournit un identifiant unique d’événement.
$tolerance = 300; // 5 minutes
if ( abs( time() - (int) $horodatage_recu ) > $tolerance ) {
    return new WP_Error( 'expired_webhook', 'Requête expirée', array( 'status' => 401 ) );
}

Le cas d’un secret maison plutôt qu’un service tiers

Quand le webhook provient d’un service développé en interne plutôt que de Stripe ou GitHub, le principe reste identique mais vous choisissez vous-même le format : incluez systématiquement un horodatage dans le message signé, générez le secret avec une source aléatoire cryptographiquement sûre (wp_generate_password( 64, false ) ou l’équivalent côté service émetteur), et stockez ce secret hors du dépôt de code, dans une variable d’environnement plutôt que dans une option WordPress en clair.

Une astuce qui nous a évité plusieurs incidents : loguer systématiquement, sans le corps complet, chaque tentative de webhook rejetée pour signature invalide. Un pic soudain de rejets est souvent le premier signe qu’un point de terminaison vient d’être découvert et testé par un tiers.

En résumé

Un webhook non signé est une porte d’entrée qui ne demande aucune authentification, ce qui en fait une cible de choix. La combinaison signature HMAC calculée sur le corps brut, comparaison via hash_equals(), et fenêtre de tolérance sur l’horodatage couvre l’essentiel des scénarios d’abus, pour un coût d’implémentation de quelques dizaines de lignes seulement.

Partager :

À propos de l'auteur

Clément Hadrot

Développeur WordPress, passionné par Elementor, le FSE et l’automatisation par IA.

Voir tous ses articles

Dans la même veine

À lire aussi