Un webhook mal vérifié est une porte d’entrée ouverte. Chaque jour, des milliers de serveurs acceptent des paiements Stripe, des déploiements GitHub ou des commandes Shopify sans jamais confirmer que la requête vient bien de l’expéditeur annoncé. La faille est presque toujours la même : le code lit le corps JSON, répond 200, et ne compare jamais la signature. Ce tutoriel corrige ça pas à pas, en Python, avec un récepteur de webhooks complet qui vérifie une signature HMAC-SHA256 avant de traiter la moindre donnée.
On va construire un serveur Flask qui capture le corps brut de la requête, recalcule la signature attendue avec le secret partagé, la compare en temps constant, rejette les requêtes trop anciennes, puis s’adapte aux trois schémas de signature les plus courants (GitHub, Stripe et Shopify). Douze étapes, un projet fonctionnel à la fin, et une liste de pièges qui cassent la vérification en silence.
Pourquoi vérifier les signatures HMAC de vos webhooks
Un webhook est une requête HTTP entrante que n’importe qui peut, en théorie, forger. Si votre endpoint /webhooks/stripe accepte tout ce qui ressemble à un JSON valide, un attaquant peut envoyer un faux événement payment_intent.succeeded et déclencher une livraison gratuite. C’est exactement le type de faille que l’OWASP classe dans les problèmes d’authentification d’API : le serveur fait confiance à l’origine d’une requête sans jamais la vérifier cryptographiquement.
La signature HMAC (Hash-based Message Authentication Code) répond à ce problème simplement. Le fournisseur (GitHub, Stripe, Shopify, ou votre propre système interne) partage un secret avec vous au moment de la configuration du webhook. À chaque envoi, il calcule un hachage du corps de la requête avec ce secret et le place dans un en-tête HTTP. Votre serveur refait exactement le même calcul de son côté : si les deux empreintes correspondent, la requête vient bien de la source attendue et n’a pas été modifiée en chemin. C’est la même construction que décrit la RFC 2104, normalisée ensuite par le FIPS 198-1 du NIST.
La plupart des frameworks web ne vérifient rien par défaut, et c’est volontaire : ils n’ont aucun moyen de deviner quel secret utiliser ni quel format de signature attendre. Flask, FastAPI ou Express acceptent une requête POST tant qu’elle respecte la structure HTTP de base, indépendamment de son origine réelle. La vérification de signature est donc entièrement à la charge du développeur, ce qui explique pourquoi tant d’intégrations de webhooks en production s’en passent purement et simplement : personne n’a pris le temps de l’ajouter, et tant que le trafic reste faible, l’absence de vérification passe inaperçue.
Ce tutoriel cible spécifiquement le rôle de récepteur : vous n’êtes pas l’émetteur du webhook, vous êtes celui qui doit le valider avant de faire quoi que ce soit avec les données. C’est une nuance importante, parce que la logique de vérification côté réception a ses propres pièges (corps brut, encodage, fenêtre de tolérance temporelle) qui n’apparaissent jamais quand on ne fait que signer ses propres appels sortants. Ce point diffère volontairement de notre tutoriel sur HMAC-SHA256 pour sécuriser une API, centré sur l’émission de requêtes signées plutôt que sur leur réception.
Comment fonctionne une signature HMAC avant de coder
Le calcul repose sur trois ingrédients : un secret partagé, le corps exact de la requête (en octets, pas en objet Python déjà parsé), et une fonction de hachage, presque toujours SHA-256. La formule est HMAC(secret, message) = H((secret XOR opad) || H((secret XOR ipad) || message)), où H est la fonction de hachage et ipad/opad des constantes fixes. Vous n’aurez jamais à écrire ça vous-même : le module hmac de la bibliothèque standard Python s’en charge.
Le point sur lequel la majorité des implémentations échouent n’est pas le calcul lui-même, mais la comparaison finale. Comparer deux chaînes avec == en Python s’arrête au premier caractère différent, ce qui rend la comparaison légèrement plus rapide quand les premiers octets diffèrent. Un attaquant patient peut, en théorie, exploiter cette différence de temps pour deviner la signature octet par octet. C’est pour ça que la comparaison doit passer par hmac.compare_digest, une fonction à temps constant conçue précisément pour ce cas d’usage.
Deuxième point de friction : la plupart des fournisseurs signent le corps brut de la requête, avant tout parsing JSON. Si votre framework désérialise automatiquement le JSON puis le re-sérialise pour recalculer la signature, l’ordre des clés ou les espaces peuvent changer et la signature ne correspondra jamais, même si les données sont identiques. Le tutoriel qui suit règle ce problème dès l’étape 4. Pour un rappel des différences entre HMAC et une signature à clé publique comme ECDSA, notre comparatif HMAC vs signature numérique détaille les compromis de chaque approche.
Pourquoi HMAC plutôt qu’une signature asymétrique pour les webhooks
Une question revient souvent au moment de concevoir un système de webhooks de zéro : pourquoi choisir HMAC, une méthode à clé symétrique, plutôt qu’une signature à clé publique comme ECDSA ou Ed25519 ? La réponse tient en un mot : simplicité opérationnelle. Avec HMAC, l’émetteur et le récepteur partagent le même secret, généré une fois et distribué au moment de la configuration. Il n’y a ni paire de clés à gérer, ni infrastructure de distribution de clés publiques, ni rotation de certificat à surveiller.
Le compromis, c’est que ce secret partagé doit rester confidentiel des deux côtés. Si votre base de données de secrets fuite, tous les webhooks associés deviennent falsifiables jusqu’à la rotation. Une signature asymétrique évite ce problème puisque seule la clé privée de l’émetteur doit rester secrète : le récepteur ne détient qu’une clé publique, qui ne permet pas de forger de nouvelles signatures même si elle est exposée. C’est cette logique que suit par exemple GitHub pour la signature des commits, documentée dans notre comparatif Ed25519 vs ECDSA P-256.
Pour un webhook classique entre deux systèmes qui se connaissent déjà (un fournisseur SaaS et son client), cette distinction pèse rarement dans la balance. HMAC-SHA256 est nettement plus rapide à calculer et à vérifier qu’une signature ECDSA, ce qui compte quand un serveur doit traiter plusieurs milliers d’événements par seconde. C’est pour cette raison que GitHub, Stripe et Shopify ont tous les trois retenu HMAC pour leurs webhooks, tout en réservant les signatures asymétriques à d’autres usages comme la signature de paquets logiciels ou de commits Git.
HMAC et conformité européenne : RGPD, NIS2 et Cyber Resilience Act
Pour une équipe qui opère en France ou dans l’Union européenne, la vérification de signature d’un webhook n’est plus un simple détail technique, c’est un élément qui peut être demandé lors d’un audit. La directive NIS2 impose aux entités essentielles et importantes de documenter des mesures de gestion des risques qui couvrent explicitement l’usage de la cryptographie dans leurs systèmes d’information. Un endpoint webhook qui accepte n’importe quelle requête sans vérification de signature est exactement le genre de lacune qu’un audit de conformité relève en premier.
Le Cyber Resilience Act (CRA), dont les obligations de signalement ont commencé à s’appliquer en septembre 2026, pousse dans la même direction pour les éditeurs de logiciels : la capacité à prouver l’intégrité et l’authenticité des flux de données échangés entre composants fait partie des attentes de base. Un webhook non vérifié, capable de déclencher une action métier sur simple réception d’une requête HTTP, constitue une surface d’attaque que ces textes cherchent précisément à réduire.
Le RGPD entre également en jeu dès que le webhook transporte des données personnelles, ce qui est presque toujours le cas pour un événement de paiement ou de commande. Un attaquant capable de forger un faux webhook peut potentiellement déclencher un traitement de données personnelles non autorisé, ou pire, en extraire via les réponses de votre API si celle-ci n’est pas conçue avec prudence. Documenter que chaque webhook entrant est vérifié cryptographiquement, avec le code source à l’appui, constitue une pièce simple mais concrète à ajouter à un registre de traitement ou à une analyse d’impact.
Prérequis : outils et versions pour ce tutoriel
Ce tutoriel a été testé avec les versions suivantes, actuelles en septembre 2026. Aucune dépendance exotique : tout tourne avec la bibliothèque standard Python plus deux paquets PyPI.
| Outil | Version utilisée | Rôle dans le projet |
|---|---|---|
| Python | 3.14.7 | Interpréteur, module hmac et hashlib natifs |
| Flask | 3.1.3 | Serveur HTTP qui reçoit les webhooks |
| pytest | 9.1.1 | Tests automatisés de la logique de vérification |
| python-dotenv | 1.0.1 | Chargement du secret depuis un fichier .env |
| Gunicorn | 23.0.0 | Serveur WSGI de production |
| ngrok | dernière version stable | Exposition du serveur local pour les tests réels |
| curl | dernière version stable | Envoi de requêtes de test avec signature manuelle |
Un compte GitHub, un compte Stripe (mode test) et une boutique Shopify de développement sont utiles pour l’étape 7, mais pas obligatoires : le tutoriel fournit des exemples de charges utiles et de signatures que vous pouvez rejouer sans compte externe.
Étape 1 : Initialiser le projet Python et l’environnement virtuel
On commence par un environnement isolé, pour éviter tout conflit de version avec d’autres projets sur la même machine.
mkdir webhook-hmac-verifier
cd webhook-hmac-verifier
python3 -m venv .venv
source .venv/bin/activate
pip install flask==3.1.3 python-dotenv==1.0.1 pytest==9.1.1 gunicorn==23.0.0
pip freeze > requirements.txt
Créez ensuite l’arborescence du projet : un fichier app.py pour le serveur Flask, un module verify.py pour la logique de vérification (séparée du routage HTTP pour pouvoir la tester isolément), un dossier tests/, et un fichier .env qui ne doit jamais être versionné dans Git.
webhook-hmac-verifier/
├── app.py
├── verify.py
├── requirements.txt
├── .env
├── .gitignore
└── tests/
└── test_verify.py
Étape 2 : Générer et sécuriser le secret partagé
Si vous contrôlez les deux extrémités du webhook (par exemple un microservice interne), générez un secret aléatoire d’au moins 32 octets avec le module secrets de Python, jamais avec random, qui n’est pas cryptographiquement sûr.
python3 -c "import secrets; print(secrets.token_hex(32))"
# Exemple de sortie :
# 7f3a1c9e5d8b2f460a7c1e9d4b6f8a2c5e7d9b1f3a6c8e0d2b4f6a8c1e3d5b7f
Placez ce secret dans .env, jamais en dur dans le code :
WEBHOOK_SECRET=7f3a1c9e5d8b2f460a7c1e9d4b6f8a2c5e7d9b1f3a6c8e0d2b4f6a8c1e3d5b7f
SIGNATURE_MAX_AGE_SECONDS=300
Pour les fournisseurs externes comme GitHub, Stripe ou Shopify, le secret est généré depuis leur interface d’administration au moment de la configuration du webhook. Stockez-le de la même façon, dans une variable d’environnement séparée par fournisseur si vous en gérez plusieurs.
Étape 3 : Construire le serveur Flask qui reçoit les webhooks
Le squelette de app.py définit une route unique qui reçoit les requêtes POST. À ce stade, on ne vérifie encore rien, on met juste en place la structure.
import os
import logging
from flask import Flask, request, abort
from dotenv import load_dotenv
from verify import verify_generic_signature
load_dotenv()
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("webhook_receiver")
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]
@app.route("/webhooks/internal", methods=["POST"])
def receive_webhook():
raw_body = request.get_data()
signature_header = request.headers.get("X-Signature-256", "")
if not verify_generic_signature(raw_body, signature_header, WEBHOOK_SECRET):
logger.warning("Signature HMAC invalide, requête rejetée")
abort(401)
payload = request.get_json()
logger.info("Webhook accepté : %s", payload.get("event", "inconnu"))
return {"status": "accepted"}, 200
if __name__ == "__main__":
app.run(port=5000, debug=False)
Notez le request.get_data() plutôt que request.get_json() en première ligne : c’est le point central de l’étape suivante.
Étape 4 : Capturer le corps brut avant toute désérialisation
C’est le piège numéro un des vérifications HMAC côté réception. request.get_data() renvoie les octets exacts reçus sur le réseau, sans transformation. request.get_json(), à l’inverse, parse le JSON en dictionnaire Python : si vous appelez cette méthode en premier puis re-sérialisez le dictionnaire avec json.dumps() pour recalculer la signature, le résultat ne correspondra presque jamais à l’original, à cause de l’ordre des clés, des espaces ou de l’encodage des caractères Unicode.
La règle est simple : appelez toujours get_data() en tout premier, utilisez ces octets bruts pour le calcul de signature, et ne parsez le JSON qu’après validation réussie.
# Mauvais : le corps est déjà parsé et perdu au moment de vérifier
data = request.get_json()
computed = hmac.new(secret, json.dumps(data).encode(), hashlib.sha256)
# Bon : on capture les octets exacts avant tout parsing
raw_body = request.get_data()
computed = hmac.new(secret, raw_body, hashlib.sha256)
payload = json.loads(raw_body) # seulement après vérification
Étape 5 : Calculer et comparer la signature HMAC-SHA256 en temps constant
Voici le cœur du module verify.py. La fonction recalcule le HMAC attendu à partir du corps brut et du secret, puis compare le résultat à la signature reçue avec hmac.compare_digest, qui neutralise les attaques par mesure de temps décrites par la documentation officielle de Python.
import hmac
import hashlib
def verify_generic_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
if not signature_header:
return False
# Certains fournisseurs préfixent la signature, ex: "sha256=abcdef..."
if "=" in signature_header:
algo, received_signature = signature_header.split("=", 1)
else:
algo, received_signature = "sha256", signature_header
if algo != "sha256":
return False
expected_signature = hmac.new(
key=secret.encode("utf-8"),
msg=raw_body,
digestmod=hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected_signature, received_signature)
Trois détails qui déterminent si cette fonction marche en production ou pas : le secret doit être encodé en utf-8 avant d’être passé à hmac.new, l’objet digestmod doit correspondre exactement à celui utilisé par l’émetteur (SHA-256 dans l’immense majorité des cas, mais vérifiez toujours la documentation du fournisseur), et la comparaison finale ne doit jamais utiliser l’opérateur ==.
Étape 6 : Bloquer les attaques par rejeu avec l’horodatage
Une signature valide ne prouve que l’intégrité et l’origine du message, pas sa fraîcheur. Si un attaquant intercepte une requête webhook légitime, il peut la rejouer indéfiniment : la signature reste valide puisque le corps n’a pas changé. Stripe contourne ce problème en intégrant un horodatage dans l’en-tête de signature lui-même, sous la forme t=1735689600,v1=abcdef.... Votre code doit vérifier que cet horodatage se situe dans une fenêtre de tolérance raisonnable, en général cinq minutes.
import time
def is_timestamp_fresh(timestamp: int, max_age_seconds: int = 300) -> bool:
now = int(time.time())
return abs(now - timestamp) <= max_age_seconds
def verify_stripe_signature(raw_body: bytes, sig_header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in sig_header.split(","))
timestamp = int(parts.get("t", 0))
received_sig = parts.get("v1", "")
if not is_timestamp_fresh(timestamp):
return False
signed_payload = f"{timestamp}.".encode() + raw_body
expected_sig = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected_sig, received_sig)
GitHub et Shopify, eux, ne signent pas d’horodatage par défaut. Pour ces fournisseurs, la protection anti-rejeu passe par un identifiant unique d’événement (X-GitHub-Delivery côté GitHub) que vous stockez temporairement pour rejeter tout doublon, une technique d’idempotence plutôt qu’une vérification temporelle.
Étape 7 : Adapter le code à GitHub, Stripe et Shopify
Les trois fournisseurs les plus intégrés utilisent HMAC-SHA256, mais avec des variantes suffisantes pour casser un code générique. Le tableau ci-dessous résume les différences telles que documentées officiellement.
| Fournisseur | En-tête HTTP | Format de la signature | Horodatage inclus |
|---|---|---|---|
| GitHub | X-Hub-Signature-256 | sha256=<hex> | Non (utilise X-GitHub-Delivery pour l’idempotence) |
| Stripe | Stripe-Signature | t=<unix>,v1=<hex> | Oui, intégré à l’en-tête |
| Shopify | X-Shopify-Hmac-Sha256 | Base64 brut, sans préfixe | Non |
La différence la plus piégeuse concerne l’encodage : GitHub envoie sa signature en hexadécimal, Shopify en base64. Une comparaison qui fonctionne pour l’un plante systématiquement pour l’autre si le code ne fait pas la distinction.
import base64
def verify_github_signature(raw_body: bytes, sig_header: str, secret: str) -> bool:
if not sig_header.startswith("sha256="):
return False
received = sig_header.split("=", 1)[1]
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
def verify_shopify_signature(raw_body: bytes, sig_header: str, secret: str) -> bool:
digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode()
return hmac.compare_digest(expected, sig_header)
Pour un projet qui reçoit des webhooks de plusieurs fournisseurs, un routeur dédié évite de dupliquer la logique de vérification dans chaque fonction de vue. Voici comment brancher les trois adaptateurs derrière trois routes distinctes, chacune avec son propre secret d’environnement.
GITHUB_SECRET = os.environ["GITHUB_WEBHOOK_SECRET"]
STRIPE_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]
SHOPIFY_SECRET = os.environ["SHOPIFY_WEBHOOK_SECRET"]
@app.route("/webhooks/github", methods=["POST"])
def receive_github_webhook():
raw_body = request.get_data()
sig = request.headers.get("X-Hub-Signature-256", "")
if not verify_github_signature(raw_body, sig, GITHUB_SECRET):
abort(401)
return {"status": "accepted"}, 200
@app.route("/webhooks/stripe", methods=["POST"])
def receive_stripe_webhook():
raw_body = request.get_data()
sig = request.headers.get("Stripe-Signature", "")
if not verify_stripe_signature(raw_body, sig, STRIPE_SECRET):
abort(401)
return {"status": "accepted"}, 200
@app.route("/webhooks/shopify", methods=["POST"])
def receive_shopify_webhook():
raw_body = request.get_data()
sig = request.headers.get("X-Shopify-Hmac-Sha256", "")
if not verify_shopify_signature(raw_body, sig, SHOPIFY_SECRET):
abort(401)
return {"status": "accepted"}, 200
Chaque route reste volontairement courte : elle capture le corps brut, appelle l’adaptateur correspondant, et rejette avec un code 401 en cas d’échec. Toute la complexité de l’encodage et du format d’en-tête reste confinée dans verify.py, ce qui rend le code des routes trivial à relire lors d’une revue de sécurité.
Ce sont exactement les mêmes trois lignes de logique HMAC à chaque fois : ce qui change, c’est uniquement l’encodage de sortie et la présence ou non d’un préfixe. C’est un bon rappel que la robustesse d’une intégration webhook tient davantage à la lecture attentive de la documentation du fournisseur qu’à la complexité du code cryptographique lui-même. La vague de CVE recensée dans notre article sur les confusions d’algorithme HMAC/JWT illustre justement ce qui arrive quand ce genre de détail est mal géré à grande échelle.
Étape 8 : Tester en local avec ngrok et curl
Avant de brancher un vrai fournisseur, testez votre logique de vérification avec une requête curl signée manuellement. Ça permet de valider le calcul sans dépendre d’un service externe.
BODY='{"event":"order.created","id":42}'
SECRET="7f3a1c9e5d8b2f460a7c1e9d4b6f8a2c5e7d9b1f3a6c8e0d2b4f6a8c1e3d5b7f"
SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST http://localhost:5000/webhooks/internal \
-H "Content-Type: application/json" \
-H "X-Signature-256: sha256=$SIG" \
-d "$BODY"
# Sortie attendue :
# {"status":"accepted"}
Pour tester avec un vrai fournisseur externe (GitHub, Stripe, Shopify), votre serveur local doit être joignable depuis Internet. C’est le rôle de ngrok : il crée un tunnel public temporaire vers votre port local, ce qui permet au fournisseur de livrer de vrais webhooks pendant votre session de développement.
ngrok http 5000
# Forwarding: https://a1b2c3d4.ngrok-free.app -> http://localhost:5000
Collez l’URL générée par ngrok dans le panneau de configuration des webhooks du fournisseur, déclenchez un événement de test (Stripe et GitHub proposent tous deux un bouton “envoyer un événement de test”), et observez les logs de votre serveur Flask.
Étape 9 : Automatiser les tests avec pytest
Une fois la logique de vérification isolée dans verify.py, elle se teste sans dépendre du serveur Flask ni d’un service externe. C’est la couverture de test la plus importante du projet : une régression silencieuse ici désactive la sécurité de tous vos webhooks d’un coup.
# tests/test_verify.py
import hmac
import hashlib
from verify import verify_generic_signature
SECRET = "test-secret-32-bytes-minimum-required"
def sign(body: bytes, secret: str) -> str:
return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
def test_valid_signature_accepted():
body = b'{"event":"ping"}'
signature = sign(body, SECRET)
assert verify_generic_signature(body, signature, SECRET) is True
def test_tampered_body_rejected():
body = b'{"event":"ping"}'
signature = sign(body, SECRET)
tampered_body = b'{"event":"pong"}'
assert verify_generic_signature(tampered_body, signature, SECRET) is False
def test_wrong_secret_rejected():
body = b'{"event":"ping"}'
signature = sign(body, "wrong-secret")
assert verify_generic_signature(body, signature, SECRET) is False
def test_missing_header_rejected():
body = b'{"event":"ping"}'
assert verify_generic_signature(body, "", SECRET) is False
pytest tests/ -v
# Sortie attendue :
# tests/test_verify.py::test_valid_signature_accepted PASSED
# tests/test_verify.py::test_tampered_body_rejected PASSED
# tests/test_verify.py::test_wrong_secret_rejected PASSED
# tests/test_verify.py::test_missing_header_rejected PASSED
# 4 passed in 0.02s
Étape 10 : Déployer le récepteur en production
Le serveur de développement intégré à Flask n’est pas conçu pour la production : il n’est ni performant sous charge, ni durci contre les comportements réseau imprévus. En production, servez l’application avec Gunicorn derrière un reverse proxy (nginx ou équivalent) qui termine le TLS.
gunicorn --workers 4 --bind 127.0.0.1:8000 --access-logfile - app:app
Trois points de vigilance en production, au-delà de la seule vérification HMAC. D’abord, l’endpoint webhook doit répondre en HTTPS uniquement, sans quoi le secret et le corps de la requête circulent en clair sur le réseau. Ensuite, mettez une limite de taille sur le corps accepté (MAX_CONTENT_LENGTH côté Flask) pour éviter qu’un corps volumineux ne consomme des ressources avant même la vérification de signature. Enfin, répondez toujours 401 en cas d’échec, jamais un code générique 500 qui pourrait laisser croire à une erreur serveur plutôt qu’à un rejet volontaire.
Séparez aussi clairement les secrets par environnement. Un secret de webhook Stripe en mode test ne doit jamais être réutilisé en production, et inversement : si les deux environnements partagent la même variable d’environnement par erreur, un événement de test peut déclencher une action réelle, ou pire, une fuite du secret de test peut suffire à forger de faux événements de production si le code ne distingue pas les deux contextes. La plupart des fournisseurs génèrent d’ailleurs des secrets différents pour chaque mode, ce qui rend cette séparation simple à respecter tant qu’elle est faite dès la configuration initiale du projet.
Erreurs courantes qui cassent la vérification HMAC
La plupart des bugs de vérification HMAC ne viennent pas d’une erreur de cryptographie, mais d’un détail d’implémentation ignoré. Voici les cinq pièges les plus fréquents observés dans des intégrations réelles.
- Parser le JSON avant de vérifier. Dès que
get_json()est appelé avantget_data(), le corps brut original n’est plus disponible tel quel, et la signature recalculée à partir du dictionnaire re-sérialisé ne correspond presque jamais à l’originale. - Comparer les signatures avec l’opérateur
==. Cette comparaison n’est pas à temps constant et introduit une faille théorique d’attaque temporelle. Utilisez systématiquementhmac.compare_digest. - Confondre hexadécimal et base64. GitHub envoie du hexadécimal, Shopify du base64. Appliquer le mauvais encodage produit un échec de vérification à 100 % du temps, même avec un secret correct.
- Oublier la fenêtre de tolérance temporelle. Sans vérification d’horodatage, une requête interceptée peut être rejouée indéfiniment avec une signature toujours valide.
- Logger la signature ou le secret en clair. Un système de journalisation qui capture les en-têtes bruts peut exposer le secret partagé dans des logs centralisés, souvent moins protégés que la base de données principale.
Dépannage : les problèmes les plus fréquents et leurs solutions
Voici les huit situations qui reviennent le plus souvent lors de la mise en place d’une vérification HMAC de webhook, avec la cause probable et la correction à appliquer.
| Symptôme | Cause probable | Solution |
|---|---|---|
| La signature ne correspond jamais, même avec le bon secret | Le corps a été parsé en JSON puis re-sérialisé avant le calcul | Utiliser request.get_data() et calculer le HMAC sur ces octets bruts |
| Ça fonctionne en local mais échoue en production | Un proxy ou middleware modifie le corps de la requête (compression, réécriture) | Désactiver toute transformation du corps avant le point de vérification |
Erreur KeyError sur l’en-tête de signature | Le nom de l’en-tête diffère selon le fournisseur (casse, tirets) | Utiliser request.headers.get() avec une valeur par défaut, jamais l’accès direct par clé |
| Signature valide pour GitHub mais pas pour Shopify avec le même code | Encodage hexadécimal appliqué à une signature qui devrait être en base64 | Vérifier l’encodage attendu dans la documentation de chaque fournisseur |
| Les tests unitaires passent mais la production rejette tout | Le secret chargé en production diffère de celui utilisé pour signer côté fournisseur | Régénérer le secret côté fournisseur et le synchroniser exactement dans la variable d’environnement |
| Vérification intermittente, échoue sous charge | Le corps de la requête est lu deux fois et le flux est déjà consommé la deuxième fois | Ne lire le corps qu’une seule fois et réutiliser la même variable partout |
| La vérification échoue uniquement pour les caractères accentués | Le corps a été ré-encodé avec un jeu de caractères différent de l’original (UTF-8 vs Latin-1) | Ne jamais décoder puis ré-encoder le corps ; conserver les octets bruts jusqu’à la vérification |
| Les requêtes de test ngrok sont rejetées par erreur | ngrok ajoute une page d’avertissement HTML avant la première requête, qui altère le corps perçu | Ajouter l’en-tête ngrok-skip-browser-warning ou utiliser directement un client HTTP plutôt qu’un navigateur |
Astuces avancées pour aller plus loin
Une fois la vérification de base en place, plusieurs améliorations renforcent la résilience du système sans complexifier excessivement le code.
Rotation du secret sans interruption de service. Un secret webhook doit pouvoir être renouvelé sans casser les livraisons en transit. La technique standard consiste à accepter temporairement deux secrets (ancien et nouveau) pendant une fenêtre de transition, en essayant la vérification avec chacun avant de rejeter la requête. Une fois la fenêtre passée et confirmé que le fournisseur utilise bien le nouveau secret, l’ancien est supprimé.
def verify_with_rotation(raw_body: bytes, sig_header: str, current_secret: str, previous_secret: str | None) -> bool:
if verify_generic_signature(raw_body, sig_header, current_secret):
return True
if previous_secret and verify_generic_signature(raw_body, sig_header, previous_secret):
return True
return False
Alerting sur les échecs répétés. Un taux anormal de signatures invalides sur un même endpoint est souvent le signe d’un secret mal synchronisé après une rotation, ou d’une tentative d’intrusion active. Journaliser un compteur d’échecs par adresse IP source (sans jamais logger la signature elle-même) permet de déclencher une alerte avant que le problème ne passe inaperçu. La vague de vulnérabilités décrite dans CVE-2026-85394 sur python-jose montre ce qui peut arriver quand une bibliothèque de vérification de signature échoue silencieusement plutôt que de rejeter clairement une requête malformée.
Idempotence des événements. Même avec une signature valide, un même événement peut être livré plusieurs fois par le fournisseur (c’est un comportement documenté et attendu, pas une anomalie). Stocker les identifiants d’événements déjà traités, avec une expiration de quelques heures, évite de dupliquer une action métier comme l’envoi d’un e-mail ou le déclenchement d’une expédition. Si vous cherchez à renforcer aussi le stockage de vos secrets applicatifs, notre tutoriel Argon2id en Python et Node.js couvre le hachage des mots de passe et des identifiants associés à ces webhooks.
Attention aux CDN et reverse proxies qui réécrivent le corps. Certains CDN ou pare-feu applicatifs décompressent, reformattent ou ajoutent des espaces au corps JSON avant de le transmettre à votre application, ce qui casse silencieusement la vérification même si le fournisseur du webhook n’a rien changé de son côté. Si vous placez votre récepteur derrière Cloudflare ou un load balancer applicatif, testez explicitement que le corps qui atteint Flask est identique octet pour octet à celui envoyé par le fournisseur, par exemple en comparant une capture réseau côté fournisseur (quand l’outil de test le permet) avec ce que reçoit request.get_data().
Observer le taux d’échec de vérification comme une métrique de sécurité. Exposer un compteur du nombre de signatures rejetées par route, par exemple via un client Prometheus, transforme un problème silencieux en signal visible. Un pic soudain d’échecs sur /webhooks/stripe après un déploiement pointe presque toujours vers un secret mal chargé ; un flux constant et bas d’échecs, réparti sur plusieurs adresses IP, ressemble davantage à une tentative de sondage automatisé de l’endpoint.
Le projet complet, prêt à copier-coller
Voici l’assemblage final de verify.py, regroupant la vérification générique et les trois adaptateurs par fournisseur construits au fil du tutoriel.
# verify.py — module complet
import hmac
import hashlib
import base64
import time
def is_timestamp_fresh(timestamp: int, max_age_seconds: int = 300) -> bool:
return abs(int(time.time()) - timestamp) <= max_age_seconds
def verify_generic_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
if not signature_header:
return False
algo, _, received = signature_header.partition("=")
if not received:
algo, received = "sha256", algo
if algo != "sha256":
return False
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
def verify_github_signature(raw_body: bytes, sig_header: str, secret: str) -> bool:
return verify_generic_signature(raw_body, sig_header, secret)
def verify_shopify_signature(raw_body: bytes, sig_header: str, secret: str) -> bool:
digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode()
return hmac.compare_digest(expected, sig_header)
def verify_stripe_signature(raw_body: bytes, sig_header: str, secret: str) -> bool:
try:
parts = dict(p.split("=", 1) for p in sig_header.split(","))
timestamp = int(parts["t"])
received_sig = parts["v1"]
except (KeyError, ValueError):
return False
if not is_timestamp_fresh(timestamp):
return False
signed_payload = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received_sig)
Ce module, associé au serveur Flask de l’étape 3 et aux tests pytest de l’étape 9, forme un récepteur de webhooks complet, capable de valider GitHub, Stripe et Shopify avec la même base de code. Pour un système interne où vous contrôlez l’émetteur et le récepteur, verify_generic_signature suffit telle quelle. Pour approfondir les fondations mathématiques du hachage utilisé ici, la catégorie cryptographie de shattered.io regroupe l’ensemble de nos tutoriels et analyses sur le sujet.
Questions fréquentes
Faut-il utiliser SHA-256 ou un autre algorithme de hachage pour HMAC ?
SHA-256 est le standard de facto pour la signature de webhooks en 2026 : c’est ce qu’utilisent GitHub, Stripe et Shopify. SHA-1 reste présent dans certaines intégrations historiques mais ne doit plus être choisi pour un nouveau projet, ses propriétés de résistance aux collisions étant affaiblies.
Pourquoi ma signature ne correspond jamais alors que le secret est correct ?
Dans la grande majorité des cas, c’est parce que le corps de la requête a été modifié entre la réception et le calcul de la signature, souvent par un parsing JSON prématuré. Vérifiez que vous utilisez bien les octets bruts capturés avant toute désérialisation.
La vérification HMAC remplace-t-elle le HTTPS ?
Non. HMAC garantit l’authenticité et l’intégrité du message, mais ne chiffre rien. Sans HTTPS, le secret et le corps de la requête peuvent être interceptés en clair sur le réseau. Les deux mécanismes sont complémentaires, pas interchangeables.
Peut-on utiliser Flask en production tel quel, sans Gunicorn ?
Non. Le serveur de développement de Flask n’est pas dimensionné pour gérer plusieurs requêtes concurrentes de façon fiable. En production, servez toujours l’application via Gunicorn (ou un serveur WSGI équivalent) derrière un reverse proxy qui gère le TLS.
Comment tester la vérification sans compte Stripe ou GitHub actif ?
Signez manuellement un corps de test avec openssl dgst -sha256 -hmac ou le module hmac de Python, comme montré à l’étape 8. Cela valide la logique de vérification indépendamment de tout service externe.
Que faire si deux fournisseurs différents envoient des webhooks vers le même endpoint ?
Séparez les routes (par exemple /webhooks/github et /webhooks/stripe) et appliquez la fonction de vérification correspondante à chacune. Mélanger les schémas de signature dans un seul point d’entrée générique complique inutilement le code et augmente le risque d’erreur de configuration.
Faut-il chiffrer le secret webhook dans la base de données ?
Si le secret est stocké en base plutôt qu’en variable d’environnement (cas fréquent pour une plateforme SaaS qui gère des webhooks pour plusieurs clients), il doit être chiffré au repos, pas seulement protégé par les permissions de la base. Un accès en lecture à la table suffit sinon à compromettre tous les webhooks du système.
La longueur du secret a-t-elle une importance ?
Oui. Un secret trop court réduit l’espace de recherche pour une attaque par force brute hors ligne. 32 octets générés avec secrets.token_hex(32) constituent une base solide, cohérente avec les recommandations habituelles pour les clés HMAC.
Peut-on appliquer cette même logique en dehors de Python ?
Oui, le principe reste identique dans n’importe quel langage : capturer le corps brut, recalculer le HMAC avec le même algorithme et le même secret, puis comparer en temps constant. Seule l’implémentation change. Node.js dispose du module natif crypto avec crypto.timingSafeEqual pour la comparaison, Go utilise crypto/hmac et hmac.Equal, et Ruby passe par OpenSSL::HMAC combiné à Rack::Utils.secure_compare. Le principal risque en changeant de langage est d’oublier l’équivalent local de la comparaison à temps constant, qui n’est pas toujours activé par défaut.




