Les webhooks sont des callbacks HTTP asynchrones server-to-server qui notifient un système distant d'un événement (paiement reçu, commit Git pushé, message Slack posté, conversion analytics). Pattern dominant pour les intégrations event-driven moderne, ils introduisent des risques sécurité spécifiques que beaucoup d'implémentations négligent : absence de signature permettant le spoofing, replay attacks par retransmission de webhook capturé, SSRF côté receiver via URL configurable, secrets faibles ou rotation absente, idempotency manquante créant des incohérences métier. Le standard de facto pour sécuriser les webhooks en 2026 reste HMAC SHA-256 avec timestamp anti-replay (pattern adopté par Stripe, GitHub, Slack, Twilio, GitLab depuis 2015-2018), complété éventuellement par mTLS pour les intégrations B2B critiques. L'initiative Standard Webhooks (Svix, 2022) tente d'unifier les pratiques avec un format commun adopté par Resend, Clerk, Supabase, Vercel et autres en 2024-2026. Cet article détaille les 7 risques sécurité spécifiques aux webhooks, les 4 défenses obligatoires (signature HMAC, anti-replay, idempotency, retry sécurisé), les patterns d'implémentation Stripe/GitHub/Slack, les défenses spécifiques côté sender et côté receiver, le SSRF receiver, les outils 2026 (Svix, Hookdeck, ngrok pour dev), et les pièges récurrents observés en production.
Définition et positionnement
Un webhook est un callback HTTP envoyé par un système (sender, source) vers un endpoint configuré d'un autre système (receiver, destination) lorsqu'un événement spécifique se produit. Pattern asynchrone, server-to-server, push-based.
Différences avec API call classique
| Dimension | API call (request/response) | Webhook (callback) |
|---|---|---|
| Initiation | Client appelle serveur | Sender notifie receiver |
| Synchronicité | Synchrone (réponse attendue) | Asynchrone (fire-and-forget côté sender) |
| Authentification | Client s'authentifie auprès serveur | Sender prouve son identité au receiver |
| Trafic | Souvent comptabilisé côté client | Comptabilisé côté receiver (souvent absorbé) |
| Endpoint exposé | Serveur API | Receiver doit exposer endpoint public |
| Retry logic | Côté client | Côté sender |
Cas d'usage typiques
- Paiement : Stripe envoie webhook quand
payment_intent.succeeded, votre app marque commande comme payée. - DevOps : GitHub envoie webhook sur
push, déclenche pipeline CI/CD. - Communication : Slack envoie webhook sur message
event_callback, votre bot répond. - Marketing : Mailchimp envoie webhook sur ouverture email, votre CRM update lead score.
- IoT : devices envoient webhook au cloud sur événement (température dépassée, mouvement détecté).
Les 7 risques sécurité des webhooks
Risques spécifiques au pattern webhook que beaucoup d'implémentations ignorent.
1. Spoofing (absence de signature)
Sans signature, n'importe qui peut envoyer de faux webhooks à votre receiver. Si le receiver fait confiance, l'attaquant déclenche des actions illégitimes (faux paiement reçu, faux événement utilisateur, etc.).
Défense : signature HMAC obligatoire, vérifiée à chaque webhook reçu.
2. Replay attack
Un webhook légitime intercepté (par MITM ou via logs compromis) peut être rejoué par l'attaquant pour déclencher l'action plusieurs fois.
Défense : timestamp dans le payload signé, refus si trop ancien (5 min max). Plus event_id avec déduplication côté receiver.
3. SSRF côté receiver
Si votre service permet à des utilisateurs de configurer une URL webhook (callback dashboard), un attaquant peut configurer une URL pointant vers une ressource interne et exfiltrer des credentials cloud (IMDS) ou exposer des services internes.
Défense : validation URL avec allowlist domaines, refus IPs privées, refus redirections, timeout strict.
4. Secret faible ou exposé
Le secret HMAC partagé entre sender et receiver est la racine de la sécurité. Secret faible (court, prédictible), partagé en clair (email, Slack), commit dans Git, jamais rotaté = compromission immédiate.
Défense : secret généré cryptographiquement (32+ caractères aléatoires), partage sécurisé (vault, secret manager), rotation régulière (90-180 jours).
5. Idempotency manquante
Webhook traité plusieurs fois (retry, replay, bug réseau) provoque des effets dupliqués (commande créée deux fois, email envoyé deux fois, paiement compté deux fois).
Défense : event_id dans payload, déduplication côté receiver via cache Redis. Logique métier idempotente quand possible.
6. Retry mal géré
Receiver qui retourne HTTP 200 sur webhook invalide pour "arrêter les retries" empêche le debugging. Receiver qui retourne 5xx en cascade sature le sender. Sender qui retry indéfiniment génère du DoS auto-infligé.
Défense : retourner 2xx uniquement si webhook accepté, 4xx si invalide (pas de retry attendu), 5xx pour erreurs temporaires (retry attendu). Sender : exponential backoff avec max retries (5 typiquement).
7. Exposition de données sensibles
Webhook payload contient souvent des données sensibles (PII, paiement, événement business). Logged en clair côté sender ou receiver, ces données fuient.
Défense : ne pas logger les payloads webhook complets en production. Si nécessaire, masquer les champs sensibles (PII, tokens, credentials).
Défense côté sender
Vous êtes le service qui envoie les webhooks à des partenaires.
Pattern de référence : Stripe-style
import hmac
import hashlib
import time
import json
import secrets
import requests
# Secret unique par receiver (généré à la création du webhook endpoint)
RECEIVER_SECRETS = {
"https://partner1.example.test/webhooks/payment": "whsec_4eC39HqLyjWDarjtT1zdp7dc",
"https://partner2.example.test/api/stripe-events": "whsec_7tF22MnHpTXBwxiyL2xfo5kz",
}
def send_webhook(receiver_url: str, event_type: str, data: dict):
"""Envoie un webhook signé HMAC."""
secret = RECEIVER_SECRETS.get(receiver_url)
if not secret:
raise ValueError(f"No secret configured for {receiver_url}")
# Construction du payload avec event_id et timestamp
event = {
"id": f"evt_{secrets.token_urlsafe(16)}", # event_id unique
"type": event_type,
"created": int(time.time()), # timestamp Unix
"data": data,
}
payload = json.dumps(event, separators=(",", ":"))
timestamp = event["created"]
# Signature HMAC SHA-256 sur "timestamp.payload"
signed_payload = f"{timestamp}.{payload}"
signature = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
headers = {
"Content-Type": "application/json",
"X-Webhook-Id": event["id"],
"X-Webhook-Timestamp": str(timestamp),
"X-Webhook-Signature": f"sha256={signature}",
"User-Agent": "MyService-Webhooks/1.0",
}
return requests.post(receiver_url, data=payload, headers=headers, timeout=10)Retry logic exponential backoff
import time
from typing import Optional
# Délais de retry : 30s, 5min, 30min, 2h, 12h
RETRY_DELAYS_SECONDS = [30, 300, 1800, 7200, 43200]
def deliver_webhook_with_retry(
receiver_url: str,
event: dict,
max_attempts: int = 5,
):
"""Délivre un webhook avec retry exponential backoff."""
for attempt in range(max_attempts):
try:
response = send_webhook(receiver_url, event["type"], event["data"])
if 200 <= response.status_code < 300:
# Succès
log_delivery_success(event["id"], receiver_url, attempt + 1)
return True
elif 400 <= response.status_code < 500:
# Erreur permanente (4xx) : pas de retry
log_delivery_failure(event["id"], receiver_url, response.status_code, "4xx_no_retry")
return False
else:
# Erreur temporaire (5xx) : retry
log_delivery_attempt(event["id"], receiver_url, response.status_code, attempt + 1)
except requests.exceptions.RequestException as e:
log_delivery_attempt(event["id"], receiver_url, "network_error", attempt + 1)
# Attente avant retry suivant
if attempt < max_attempts - 1:
time.sleep(RETRY_DELAYS_SECONDS[attempt])
# Tous les retries échoués
log_delivery_failure(event["id"], receiver_url, "max_retries_exceeded", "alert")
return FalseProduction-grade : queue persistante
En production, ne jamais envoyer les webhooks de manière synchrone depuis le code applicatif principal. Pattern type :
- Application enregistre l'événement dans une queue persistante (Redis Streams, Kafka, AWS SQS, RabbitMQ).
- Worker dédié consomme la queue et envoie les webhooks avec retry logic.
- Dashboard sender : interface admin permettant de voir les webhooks failed et de les retrigger manuellement.
Solutions packagées :
- Svix (commercial + open source) : plateforme dédiée webhook delivery.
- Hookdeck : équivalent commercial.
- Self-hosted : Sidekiq + Redis (Ruby), Celery + RabbitMQ (Python), BullMQ + Redis (Node).
Configuration sender recommandée
- Endpoint receiver enregistré dans dashboard sender, secret HMAC généré côté serveur.
- Endpoint URL HTTPS obligatoire, refus HTTP en production.
- Rotation des secrets possible via dashboard (génération nouveau secret, période de transition où sender envoie deux signatures, suppression ancien).
- Test endpoint dans dashboard pour permettre receiver de valider l'intégration avant production.
- Logs deliveries consultables par receiver (succès, échecs, payloads).
Défense côté receiver
Vous êtes le service qui reçoit les webhooks de partenaires.
Validation HMAC
import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException, Header
app = FastAPI()
WEBHOOK_SECRET = "whsec_4eC39HqLyjWDarjtT1zdp7dc" # depuis vault, pas hardcodé
def verify_webhook_signature(
payload: bytes,
timestamp: str,
signature: str,
secret: str,
max_age_seconds: int = 300,
) -> bool:
"""Vérifie signature HMAC avec anti-replay."""
# 1. Anti-replay : refuser timestamp trop ancien
try:
ts = int(timestamp)
except ValueError:
return False
if abs(time.time() - ts) > max_age_seconds:
return False
# 2. Reconstruction signature attendue
signed_payload = f"{ts}.{payload.decode()}"
expected_signature = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# 3. Comparaison constant-time (anti-timing attack)
received_signature = signature.replace("sha256=", "")
return hmac.compare_digest(expected_signature, received_signature)
@app.post("/webhooks/payment")
async def receive_webhook(
request: Request,
x_webhook_id: str = Header(...),
x_webhook_timestamp: str = Header(...),
x_webhook_signature: str = Header(...),
):
payload = await request.body()
# 1. Vérification signature + anti-replay timestamp
if not verify_webhook_signature(
payload, x_webhook_timestamp, x_webhook_signature, WEBHOOK_SECRET
):
raise HTTPException(401, "Invalid signature")
# 2. Déduplication via event_id
if await is_already_processed(x_webhook_id):
return {"status": "already_processed"}
# 3. Traitement async (queue interne) pour répondre rapidement
event = json.loads(payload)
await enqueue_event_processing(event)
# 4. Mark as processed pour déduplication future
await mark_processed(x_webhook_id, ttl_seconds=86400 * 7) # 7 jours
# 5. Réponse 2xx rapide
return {"status": "accepted"}Déduplication côté receiver
import redis
r = redis.Redis(host="redis.internal", decode_responses=True)
async def is_already_processed(event_id: str) -> bool:
"""Vérifie si un event_id a déjà été traité (dans les 7 derniers jours)."""
key = f"webhook:processed:{event_id}"
return await r.exists(key) > 0
async def mark_processed(event_id: str, ttl_seconds: int = 604800):
"""Marque un event_id comme traité avec TTL."""
key = f"webhook:processed:{event_id}"
await r.set(key, int(time.time()), ex=ttl_seconds)Idempotency au niveau métier
Au-delà de la déduplication par event_id, viser des opérations métier intrinsèquement idempotentes.
# IDEMPOTENT : update conditionnel
def handle_payment_succeeded(event_data):
payment_id = event_data["payment_id"]
order = db.query(Order).filter_by(payment_id=payment_id).first()
# Update conditionnel : ne fait rien si déjà 'paid'
if order and order.status != "paid":
order.status = "paid"
order.paid_at = datetime.now(UTC)
db.commit()
send_confirmation_email(order)
# NON IDEMPOTENT : incrémenter un compteur
def handle_order_created(event_data):
user = db.query(User).filter_by(id=event_data["user_id"]).first()
user.orders_count += 1 # ← problème si event traité 2x
db.commit()
# IDEMPOTENT : recalcul depuis la source de vérité
def handle_order_created_idempotent(event_data):
user_id = event_data["user_id"]
actual_count = db.query(Order).filter_by(user_id=user_id).count()
db.query(User).filter_by(id=user_id).update({"orders_count": actual_count})
db.commit()Endpoint webhook sécurisé
# Configuration recommandée endpoint webhook receiver
@app.post(
"/webhooks/{provider}",
# Pas dans la doc OpenAPI publique (seul le sender connaît l'URL)
include_in_schema=False,
# Réponse minimale : ne pas exposer de stack trace
response_class=JSONResponse,
)
async def receive_webhook(provider: str, request: Request):
# ... validation et traitement
pass
# Configuration nginx en frontage
# - HTTPS obligatoire
# - Rate limiting (refuse si > 100 req/min depuis IP unique)
# - body size limit (refuse si > 1 MB)
# - timeout strict (refuse après 10s)Patterns d'implémentation Stripe / GitHub / Slack
Trois patterns dominants en 2026, à connaître pour intégration.
Stripe webhook signature
# Stripe : header "Stripe-Signature" avec format "t=<timestamp>,v1=<signature>"
def verify_stripe_webhook(payload: bytes, signature_header: str, secret: str) -> bool:
elements = {}
for item in signature_header.split(","):
key, value = item.split("=", 1)
elements[key] = value
timestamp = elements.get("t")
signature_v1 = elements.get("v1")
if not timestamp or not signature_v1:
return False
# Vérification timestamp (5 min max)
if abs(time.time() - int(timestamp)) > 300:
return False
# Reconstruction signature
signed_payload = f"{timestamp}.{payload.decode()}"
expected = hmac.new(
secret.encode(), signed_payload.encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_v1)
# Usage :
# Stripe envoie header :
# Stripe-Signature: t=1714118400,v1=abc123...,v0=oldformatStripe utilise SDK officiel stripe.Webhook.construct_event(payload, signature_header, secret) qui encapsule cette logique.
GitHub webhook signature
# GitHub : header "X-Hub-Signature-256" avec format "sha256=<signature>"
def verify_github_webhook(payload: bytes, signature_header: str, secret: str) -> bool:
if not signature_header.startswith("sha256="):
return False
received_signature = signature_header[len("sha256="):]
expected = hmac.new(
secret.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, received_signature)
# GitHub n'inclut pas timestamp dans signature (anti-replay au niveau event_id GUID)
# Dedup via header X-GitHub-Delivery (GUID unique par event)GitHub utilise SHA-1 historique (X-Hub-Signature, déprécié) et SHA-256 (X-Hub-Signature-256, recommandé).
Slack signing secret
# Slack : headers "X-Slack-Request-Timestamp" et "X-Slack-Signature"
def verify_slack_webhook(payload: bytes, timestamp: str, signature: str, secret: str) -> bool:
# Anti-replay 5 minutes
if abs(time.time() - int(timestamp)) > 300:
return False
# Format : v0=<signature>, signed_payload = "v0:timestamp:body"
signed_payload = f"v0:{timestamp}:{payload.decode()}"
expected = "v0=" + hmac.new(
secret.encode(), signed_payload.encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)Standard Webhooks (Svix)
# Standard Webhooks : 4 headers webhook-id, webhook-timestamp, webhook-signature
# Format signature : "v1,<base64-encoded-signature>"
import base64
def verify_standard_webhook(
payload: bytes,
msg_id: str,
timestamp: str,
signature_header: str,
secret: str,
) -> bool:
# Anti-replay
if abs(time.time() - int(timestamp)) > 300:
return False
# Décodage secret base64 (format svix)
if secret.startswith("whsec_"):
secret_bytes = base64.b64decode(secret[len("whsec_"):])
else:
secret_bytes = secret.encode()
# Signature : HMAC-SHA256 de "msg_id.timestamp.payload"
signed_payload = f"{msg_id}.{timestamp}.{payload.decode()}"
expected_sig = base64.b64encode(
hmac.new(secret_bytes, signed_payload.encode(), hashlib.sha256).digest()
).decode()
# Format header : "v1,sig1 v1,sig2" (peut contenir plusieurs signatures pour rotation)
for sig_pair in signature_header.split(" "):
version, sig = sig_pair.split(",", 1)
if version == "v1" and hmac.compare_digest(expected_sig, sig):
return True
return FalseLibrary officielle Svix : pip install svix ou npm install svix.
SSRF côté receiver : cas spécifique
Si votre service permet aux utilisateurs de configurer leur URL webhook (callback dashboard), votre service devient potentiellement vulnerable à SSRF.
Scénario d'attaque
1. Attaquant crée compte sur votre service.
2. Attaquant configure l'URL de webhook : http://169.254.169.254/latest/meta-data/iam/security-credentials/
3. Votre service tente d'envoyer un webhook vers cette URL.
4. La réponse contient les credentials IAM de l'instance EC2.
5. Si les logs deliveries sont visibles à l'attaquant (dashboard), exfiltration des credentials.Défense : validation stricte URL
import socket
import ipaddress
from urllib.parse import urlparse
PRIVATE_NETWORKS = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"), # link-local, IMDS
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("::1/128"), # IPv6 localhost
ipaddress.ip_network("fc00::/7"), # IPv6 ULA
ipaddress.ip_network("fe80::/10"), # IPv6 link-local
]
def is_safe_webhook_url(url: str) -> bool:
"""Valide qu'une URL webhook ne pointe pas vers infrastructure interne."""
parsed = urlparse(url)
# 1. HTTPS uniquement
if parsed.scheme != "https":
return False
# 2. Hostname valide
if not parsed.hostname:
return False
# 3. Résoudre en IP et vérifier
try:
addresses = socket.getaddrinfo(parsed.hostname, None)
except socket.gaierror:
return False
for family, _, _, _, sockaddr in addresses:
ip_str = sockaddr[0]
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
continue
# Refuser IPs privées
for network in PRIVATE_NETWORKS:
if ip in network:
return False
# Refuser IPs réservées / multicast
if ip.is_reserved or ip.is_multicast or ip.is_loopback:
return False
return True
# Usage à la configuration du webhook par l'utilisateur
def configure_webhook(url: str, user: User):
if not is_safe_webhook_url(url):
raise HTTPException(400, "Webhook URL must point to a public HTTPS endpoint")
# ... save webhook configurationDéfenses additionnelles côté sender
- Timeout strict : 10 secondes max par requête HTTP webhook.
- Refus de redirections :
requests.post(..., allow_redirects=False). - Limite de réponse : ne pas charger plus de 1 KB de la réponse receiver (économie + protection).
- Network isolation : worker webhook deployé dans subnet sans accès aux ressources internes (security group strict, no IMDS access via IMDSv2 enforced).
- DNS rebinding protection : revalider l'IP après résolution DNS, refuser si IP différente entre la validation et la requête réelle.
Outils et plateformes 2026
Stack pratique pour développer, tester et opérer des webhooks.
Pour développement local
| Outil | Usage |
|---|---|
| ngrok | Tunnel HTTPS public vers localhost pour recevoir webhooks en dev |
| Cloudflare Tunnel | Alternative gratuite à ngrok |
| LocalTunnel | Alternative open source |
| webhook.site | Endpoint public temporaire pour debug, voir les payloads reçus |
| RequestBin | Similaire, archive les requêtes pour inspection |
Pour production
| Outil | Type | Particularité |
|---|---|---|
| Svix | Commercial + OSS | Plateforme dédiée webhooks comme service, retry logic, dashboard, signatures |
| Hookdeck | Commercial | Concurrent direct Svix |
| Pipedream | Commercial low-code | Webhook routing + workflow automation |
| AWS EventBridge | AWS managed | Pour intégration AWS-centric |
| Apache Kafka avec REST proxy | Self-hosted | Pour très grande échelle |
Standards et bibliothèques
- Standard Webhooks : standardwebhooks.com — initiative ouverte 2022 par Svix.
- Svix libraries : Python, Node, Go, Ruby, Java, .NET — implémente Standard Webhooks signature verification.
- stripe-python, stripe-node : SDK officiel Stripe avec webhook construct_event.
- PyGitHub : webhook validation pour GitHub.
- slack-bolt (Python, Node) : framework Slack avec verification automatique.
Pièges récurrents en production
Cinq erreurs observées sur les implémentations webhook 2024-2026.
1. Vérification signature non constant-time
# MAUVAIS : comparaison directe de strings
if expected_signature == received_signature: # vulnerable timing attack
process()
# CORRECT : constant-time
if hmac.compare_digest(expected_signature, received_signature):
process()2. Secret en clair dans la configuration
Secrets webhook commit dans Git ou stockés en base de données en clair. Solution : vault (HashiCorp Vault, AWS Secrets Manager) avec rotation.
3. Pas de déduplication
Receiver traite chaque webhook reçu sans vérifier event_id. Retry / replay créent des effets dupliqués. Solution : déduplication systématique via Redis cache.
4. Endpoint webhook public découvrable
URL webhook prédictible (https://api.example.test/webhooks/stripe) sans IP allowlist Stripe (Stripe publie sa liste d'IPs). Tout le monde peut envoyer des faux webhooks (bloqués par signature mais pollutent les logs et consomment CPU).
Défense : IP allowlist au reverse proxy ou WAF.
5. Logging des payloads en clair
Les payloads webhook contiennent souvent des données sensibles (PII, paiement, événement). Logger en clair = fuite via logs.
Défense : ne pas logger les payloads complets en production. Si nécessaire, masquer les champs sensibles.
SENSITIVE_FIELDS_PATHS = ["data.email", "data.phone", "data.card.number"]
def mask_sensitive(payload: dict) -> dict:
masked = copy.deepcopy(payload)
for path in SENSITIVE_FIELDS_PATHS:
keys = path.split(".")
target = masked
for key in keys[:-1]:
target = target.get(key, {})
if keys[-1] in target:
target[keys[-1]] = "****"
return masked
logger.info("Webhook received", extra={"event": mask_sensitive(payload)})Points clés à retenir
- Les webhooks sont des callbacks HTTP server-to-server asynchrones, dominants pour intégrations B2B (Stripe, GitHub, Slack, Twilio). Risques sécurité spécifiques : spoofing, replay, SSRF receiver, secrets faibles, idempotency manquante, retry mal géré, exposition data sensible.
- Standard de défense 2026 : signature HMAC SHA-256 du payload + timestamp anti-replay (5 min max) + event_id unique pour déduplication. Pattern adopté par Stripe, GitHub, Slack, Twilio depuis 2015-2018.
- Défense côté receiver : validation HMAC constant-time avec hmac.compare_digest, anti-replay timestamp, déduplication via cache Redis, idempotency au niveau métier, réponse rapide 2xx avec traitement async via queue.
- Défense côté sender : queue persistante, retry exponential backoff (5 max), validation URL anti-SSRF avec allowlist + refus IPs privées + protection DNS rebinding, isolation réseau du worker webhook.
- Standard Webhooks (Svix, 2022) tente d'unifier les pratiques avec format commun, adopté par Resend, Clerk, Supabase, Vercel. Stripe, GitHub, Slack restent sur leurs formats propriétaires éprouvés.
Pour aller plus loin
- Authentification d'API : les bases - HMAC dans le panorama des 7 mécanismes d'authentification API.
- Qu'est-ce que la sécurité des API - vue d'ensemble incluant webhooks comme cas d'usage.
- Exposition excessive de données API - applicable aux payloads webhook qui peuvent surexposer.
- API Gateway : rôle sécurité - couche transverse qui peut centraliser la validation HMAC webhooks.





