Les signatures de webhook, expliquées

Une signature de webhook est une courte chaîne que le service expéditeur attache à chaque livraison : un HMAC calculé sur les octets exacts de la requête avec un secret que seuls l’expéditeur et le destinataire partagent. Elle existe pour que le serveur destinataire puisse répondre à deux questions avant d’agir : cela vient-il vraiment du service dont cela prétend venir, et le corps est-il arrivé exactement tel qu’il a été envoyé ? Cet article explique comment cette vérification fonctionne réellement : ce que le HMAC couvre, pourquoi il doit être contrôlé sur le corps brut plutôt que sur un objet déjà analysé, pourquoi la comparaison doit s’exécuter en temps constant, comment les horodatages bornent les attaques par relecture, et pourquoi chaque fournisseur formate l’en-tête différemment. Si vous voulez d’abord voir une livraison réelle — en-têtes, signature et corps brut réunis —, pointez un expéditeur vers le testeur de webhooks et inspectez ce qui arrive.

Pourquoi une URL secrète n’est pas une authentification

La plus ancienne protection de webhook est une URL impossible à deviner : un long segment de chemin aléatoire que seul le service expéditeur connaît. Elle ressemble à un mot de passe, mais se comporte comme une adresse, et les adresses fuient. L’URL se retrouve écrite dans les journaux d’accès, les journaux de proxies, les tableaux de bord de supervision, les captures d’écran de rapports de bogues et les pages de configuration du service expéditeur lui-même, où n’importe quel ingénieur de support — ou quiconque compromet un seul de ces systèmes — peut la lire. Un mot de passe peut être stocké haché et renouvelé par utilisateur ; une URL est copiée verbatim partout où elle sert, et chaque copie est une invitation permanente.

Le problème plus profond est ce que le point de terminaison fait d’une requête une fois qu’elle arrive. Les gestionnaires de webhooks marquent des factures comme payées, provisionnent des comptes, accordent des licences, suppriment des données. Ce sont des actions qui valent la peine d’être contrefaites, et une requête contrefaite coûte à un attaquant un unique POST HTTP depuis n’importe où sur internet. TLS n’aide pas ici, car TLS authentifie votre serveur auprès de quiconque se connecte ; il ne dit rien sur l’identité de la partie qui se connecte. Une signature déplace la confiance de « sait où frapper » vers « connaît un secret qui n’apparaît jamais dans la requête elle-même », ce qui est une affirmation catégoriquement plus forte.

Ce qu’une signature HMAC prouve réellement

L’expéditeur et le destinataire partagent un secret, généré lors de l’enregistrement du point de terminaison. Pour chaque livraison, l’expéditeur calcule un HMAC — un hachage à clé — sur un ensemble défini d’octets : généralement le corps brut de la requête, souvent précédé d’un horodatage, parfois accompagné d’un identifiant de livraison. Le résultat voyage dans un en-tête. Le destinataire recalcule le HMAC sur les octets qu’il a reçus, avec sa propre copie du secret, et compare les deux valeurs. Une correspondance prouve deux choses à la fois : l’expéditeur possédait le secret, et les octets couverts sont, bit pour bit, les octets que l’expéditeur a signés. Changez un seul caractère du champ du montant et la valeur recalculée diverge complètement.

Deux limites méritent d’être énoncées clairement. D’abord, une signature n’est pas un chiffrement : le corps traverse le réseau lisible par quiconque est en position de le voir — le garder privé est le travail de TLS — et la signature ne se porte garante que de l’origine et de l’intégrité. Ensuite, un simple hachage du corps ne prouverait rien, car n’importe qui peut hacher n’importe quoi. C’est le secret qui transforme un hachage en code d’authentification de message. Le générateur de hachages de ce site calcule des hachages sans clé, qui conviennent comme sommes de contrôle — mais une somme de contrôle que n’importe qui peut recalculer n’authentifie personne.

Vérifiez le corps brut, octet par octet

La cause la plus fréquente de « la vérification échoue sur des livraisons authentiques » n’est pas un attaquant : c’est le framework du destinataire lui-même. La signature a été calculée sur le corps exactement tel qu’il a été transmis : cet ordre de clés, ces espaces, ces caractères échappés, ce formatage des nombres. La plupart des frameworks web analysent obligeamment le corps JSON en un objet avant que votre gestionnaire ne s’exécute, et si vous resérialisez cet objet pour vérifier, vous obtenez les mêmes données dans des octets différents : clés réordonnées, espaces ajoutés, 1e3 rendu en 1000, unicode échappé ou déséchappé. Identiques en sémantique, différents en cryptographie — et la vérification échoue sur une livraison qui était parfaitement authentique. La documentation de Stripe énonce la règle sans détour : la vérification de signature exige le corps brut, et le framework ne doit pas le manipuler.

La conséquence opérationnelle est une question d’ordre : capturez le corps brut de la requête avant que quoi que ce soit ne l’analyse, vérifiez la signature sur ces octets, et seulement ensuite analysez et traitez. Les proxies et les middlewares qui réécrivent les corps — réencodage, normalisation, jolie mise en forme — brisent les signatures aussi sûrement qu’un analyseur, et c’est pourquoi le débogage commence par regarder la requête exactement telle qu’elle est arrivée. C’est la vue que vous donne un outil de réception : lorsqu’une livraison atterrit dans le testeur de webhooks, ce que vous inspectez est la requête intacte, en-têtes et corps tels que transmis, contre laquelle une signature peut être contrôlée honnêtement.

Comparaison en temps constant, et pourquoi l’horloge fait partie de la signature

Comparer les deux valeurs HMAC a l’air de l’étape triviale, et elle cache une fuite subtile. Une comparaison de chaînes ordinaire s’arrête au premier caractère différent, donc elle répond plus vite quand une tentative échoue tôt et plus lentement quand un préfixe plus long se trouve être juste. Au fil de nombreuses requêtes, cette différence de temps permet à un attaquant de progresser par la mesure vers une signature valide, caractère par caractère, sans jamais connaître le secret. Une comparaison en temps constant examine toujours la longueur entière et prend le même temps, que les valeurs diffèrent au premier ou au dernier caractère. La documentation des plateformes est directe sur ce point — celle de GitHub, par exemple, met explicitement en garde contre un simple opérateur d’égalité et renvoie à des fonctions en temps constant comme crypto.timingSafeEqual. Utilisez la fonction de la bibliothèque ; n’écrivez pas vous-même la boucle de comparaison.

La fraîcheur est la troisième propriété, et une signature seule ne peut pas vous la donner : une livraison authentique capturée, rejouée verbatim, porte une signature parfaitement valide. C’est pourquoi de nombreux schémas replient un horodatage dans les octets signés, et pourquoi les destinataires rejettent les livraisons dont l’horodatage sort d’une fenêtre de tolérance — cinq minutes par défaut dans le schéma de Stripe, selon sa documentation. La fenêtre est un compromis délibéré : assez large pour absorber la dérive d’horloge et les files lentes, assez étroite pour qu’une requête capturée devienne vite obsolète. À l’intérieur de la fenêtre, la défense repose sur l’idempotence : enregistrez l’identifiant de livraison ou d’événement de tout ce que vous avez traité et écartez les doublons — ce dont vous avez de toute façon besoin, car les expéditeurs honnêtes réessaient, et un réessai est une relecture de bonne foi.

Pourquoi l’en-tête de chaque fournisseur a une autre allure

La mécanique est standard ; l’emballage n’a jamais convergé. Stripe envoie un en-tête Stripe-Signature de la forme t=<timestamp>,v1=<signature>, et peut inclure plusieurs valeurs v1 à la fois. GitHub envoie X-Hub-Signature-256, dont la valeur est la chaîne préfixée par l’algorithme sha256=<hex digest> — sans horodatage, donc une livraison GitHub n’a pas d’expiration intégrée, et la défense contre la relecture repose entièrement sur la déduplication des identifiants de livraison. La spécification Standard Webhooks est une tentative de trancher la question : des en-têtes séparés pour l’identifiant de livraison, l’horodatage et la signature, la signature étant calculée sur les trois plus le corps, afin que les nouveaux fournisseurs cessent d’inventer des variantes incompatibles. Les différences sont des accidents historiques, non des modèles de sécurité différents — dans chaque schéma, l’invariant est le même : un secret partagé, un ensemble défini d’octets couverts, une comparaison en temps constant.

Les multiples valeurs v1 du format de Stripe existent pour l’opération à laquelle tout destinataire finit par faire face : la rotation du secret. Faite naïvement — changer le secret, déployer, espérer —, la rotation casse tout ce qui est en vol, car les réessais et les livraisons en file signés avec l’ancien secret échouent soudain. Le schéma qui fonctionne est une fenêtre parallèle : pendant le chevauchement, les signatures sous l’ancien et le nouveau secret sont acceptées, vous confirmez que les livraisons vérifient sous le nouveau, et seulement alors l’ancien est retiré. Le même schéma de fenêtre apparaît dans le monde JWT des identifiants de clés et des clés qui se chevauchent, et le mode de défaillance quand les équipes le sautent est identique, comme le décrit l’article sur les erreurs JWT. Un secret qui ne peut pas être renouvelé sans interruption ne le sera pas, même quand il le devrait — y compris le jour où il fuit — et c’est pourquoi la peu glamour fenêtre parallèle fait partie du protocole, et non d’une pensée après coup.

Sources et lectures complémentaires

Questions fréquentes

La vérification échoue sur des livraisons que je sais authentiques. Par où regarder en premier ?

Par le corps. Vérifiez, dans l’ordre : contrôlez-vous les octets bruts tels que reçus, ou une version analysée puis resérialisée ? Utilisez-vous le secret de ce point de terminaison et de ce mode — les points de terminaison de test et de production ont des secrets différents, et chaque point enregistré a le sien ? Quelque chose entre l’expéditeur et votre gestionnaire — un proxy, un middleware du framework — réécrit-il le corps ? Capturer une livraison intacte, par exemple dans le testeur de webhooks, et vérifier sur ces octets exacts sépare « l’expéditeur signe bizarrement » de « ma pile modifie les octets » en une seule étape.

Nous utilisons déjà HTTPS. La signature n’est-elle pas redondante ?

Non : les deux authentifient des directions différentes. TLS prouve à l’expéditeur qu’il a atteint votre vrai serveur et chiffre le corps en transit. Il n’impose aucune exigence sur qui se connecte : n’importe qui sur internet peut ouvrir une connexion TLS parfaitement valide vers votre point de terminaison et envoyer en POST un corps contrefait « paiement réussi ». La signature est la partie qui authentifie l’expéditeur et le contenu. TLS garde une livraison privée ; la signature la rend digne de confiance.

À quelle fréquence un secret de webhook doit-il être renouvelé ?

Immédiatement, au moindre soupçon de fuite — il vit dans des tableaux de bord, des fichiers d’environnement et des variables de CI, alors considérez l’exposition comme une question de temps. Routinièrement, au rythme où votre équipe renouvelle des identifiants comparables, à condition que la rotation soit peu coûteuse : avec une fenêtre de double acceptation, c’est un changement de configuration, pas un incident. Si le schéma d’un fournisseur rend la rotation pénible, pesez-le lors du choix des intégrations et préférez les schémas publiés comme Standard Webhooks — la capacité de rotation est une propriété de sécurité du protocole, pas un raffinement.