Aller au contenu

← Tous les articles

Vérifier la signature d'un webhook : le faire correctement

Un webhook est une URL de votre application que quelqu'un d'autre appelle. N'importe qui peut deviner cette URL et y envoyer une requête qui ressemble à un événement. Sans vérification de signature, il suffit alors d'un faux événement « livré » ou « rebond » pour fausser votre base. Voici comment vérifier chaque requête, et les pièges qui font échouer la vérification alors que tout semble correct.

Les exemples utilisent les webhooks de Duva, dont la signature suit le format ouvert « Standard Webhooks » : toute bibliothèque compatible la vérifie.

Ce que vous recevez

Chaque événement arrive en POST JSON, avec trois en-têtes :

POST /hooks/duva HTTP/1.1
content-type: application/json
webhook-id: evt_0b1c2d3e4f5a46b78c9d0e1f2a3b4c5d
webhook-timestamp: 1789843104
webhook-signature: v1,g0hM9SsE+OeSQNLzdvDx0IHdBBu58Z8ZGkTmDd9wSQ4=

{"data":{"detail":{"code":550,"enhanced_code":"5.1.1"},"message_id":"msg_9f1c…","recipient":"[email protected]"},"domain":"example.com","id":"evt_0b1c…","type":"bounced"}
  • webhook-id identifie l'événement ;
  • webhook-timestamp est l'heure d'envoi (en secondes) ;
  • webhook-signature contient une ou plusieurs signatures de la forme v1,<base64>.

La signature est base64(HMAC-SHA256(secret, "<webhook-id>.<webhook-timestamp>.<corps brut>")). Le secret est la partie base64 du secret whsec_... affiché une seule fois, à la création du webhook.

Les quatre règles

1. Vérifier sur le corps brut

C'est l'erreur numéro un. La signature porte sur les octets exacts reçus. Si votre framework décode le JSON, puis que vous le ré-encodez pour vérifier, l'ordre des clés, les espaces ou les caractères accentués peuvent changer, et la signature ne correspond plus.

Lisez donc le corps avant tout décodage :

  • Node.js (Express) : express.raw({ type: "application/json" }) ;
  • Flask : request.get_data() ;
  • PHP : file_get_contents("php://input") ;
  • Go : io.ReadAll(r.Body) ;
  • Rails : request.raw_post.

2. Refuser un horodatage trop ancien

Sans vérification de l'horodatage, quelqu'un qui intercepte une requête valide peut la rejouer indéfiniment : la signature restera valide. Refusez tout webhook-timestamp de plus de 5 minutes (et un horodatage dans le futur).

3. Comparer en temps constant

Comparez la signature attendue et celle reçue avec une fonction de comparaison en temps constant (hmac.compare_digest en Python, crypto.timingSafeEqual en Node.js), pas avec ==. Cela évite de révéler, par le temps de réponse, combien de caractères étaient corrects.

4. Accepter plusieurs signatures et plusieurs secrets

L'en-tête peut contenir plusieurs signatures séparées par une espace, par exemple pendant une rotation de secret. Il suffit qu'une soit valide. De votre côté, acceptez aussi plusieurs secrets pendant la rotation : essayez-les tous, et retirez l'ancien quand plus rien ne l'utilise.

Avec la bibliothèque officielle

Le plus simple et le plus sûr est d'utiliser la fonction fournie : elle fait les quatre choses ci-dessus.

Node.js

import { constructEvent, WebhookSignatureError } from "duva-mail";

// req.headers et le corps brut, exactement comme reçus
try {
  const event = constructEvent(process.env.DUVA_WEBHOOK_SECRET, req.headers, rawBody);
  console.log(event.type, event.data);
} catch (err) {
  if (err instanceof WebhookSignatureError) {
    res.status(400).end();
    return;
  }
  throw err;
}

Python

import os

from duva import WebhookSignatureError, construct_event

try:
    event = construct_event(os.environ["DUVA_WEBHOOK_SECRET"], request.headers, raw_body)
    print(event.type, event.data)
except WebhookSignatureError:
    return "", 400

Des fonctions équivalentes existent pour PHP, Go et Ruby : voir la référence de l'API.

Et sans bibliothèque ?

Si vous préférez ne rien ajouter à votre projet, la vérification tient en quelques lignes. Voici une version en Python, écrite pour un seul secret :

import base64
import hashlib
import hmac
import time


def verify(secret: str, headers: dict, raw_body: bytes, tolerance: int = 300) -> bool:
    key = base64.b64decode(secret.removeprefix("whsec_"))
    message_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > tolerance:
        return False  # trop ancien (rejeu) ou dans le futur
    signed = f"{message_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    received = [part[3:] for part in headers["webhook-signature"].split() if part.startswith("v1,")]
    return any(hmac.compare_digest(expected, candidate) for candidate in received)

Ajoutez un try/except autour de l'accès aux en-têtes (un en-tête absent ne doit jamais faire planter le serveur) et, pendant une rotation, appelez la fonction avec chaque secret actif. Nous l'avons confrontée aux vecteurs de test publics de Duva (corps modifié, horodatage trop ancien ou dans le futur, mauvaise signature, signature inconnue, en-tête absent) : elle donne le résultat attendu pour chacun, sauf la rotation, qui exige d'essayer plusieurs secrets.

Après la vérification

Répondre vite, traiter ensuite

Duva attend une réponse 2xx pendant 10 secondes au plus. Vérifiez la signature, mettez l'événement en file (ou écrivez-le en base), répondez 200, puis faites le travail lourd. Un traitement lent qui dépasse le délai est vu comme un échec, et l'événement sera renvoyé.

Ne répondez 2xx qu'après avoir vérifié la signature : un 2xx sur une requête non vérifiée dit à l'expéditeur que tout va bien.

Dédupliquer

La livraison est au moins une fois : le même événement peut arriver plus d'une fois (même webhook-id, nouvel horodatage et nouvelle signature). Gardez les identifiants déjà traités et ignorez un doublon. Idéalement, rendez votre traitement idempotent : appliquer deux fois « le message est livré » ne doit rien changer.

Ne pas compter sur l'ordre

Aucun ordre n'est garanti entre les événements. Un delivered peut arriver avant un deferred plus ancien. Appuyez-vous sur occurred_at, et sur la logique « un statut ne recule pas ».

Ce qui se passe quand ça échoue

  • Toute réponse autre que 2xx, un délai de 10 secondes dépassé, une erreur de connexion ou de certificat : nouvelle tentative, après 30 secondes, puis en doublant jusqu'à 1 heure, pour 10 tentatives au total ;
  • les redirections ne sont pas suivies : une redirection est un échec ;
  • répondre 410 désactive le webhook : utile pour dire « n'envoyez plus » ;
  • après 100 échecs consécutifs, le webhook est désactivé et ses livraisons en attente sont abandonnées.

Une liste de vérification

  1. Le corps est lu brut, avant tout décodage.
  2. L'horodatage de plus de 5 minutes est refusé.
  3. La comparaison est en temps constant.
  4. Une signature invalide donne un 4xx et aucun effet dans votre application.
  5. Le secret whsec_... est dans une variable d'environnement, jamais dans le dépôt ni dans un journal.
  6. Les événements sont dédupliqués sur webhook-id.
  7. La réponse se fait en moins de 10 secondes.
  8. Votre point d'accès est en HTTPS : c'est exigé, et il doit être joignable depuis Internet public (les adresses privées et locales sont refusées).
  9. Le corps complet des événements n'est pas écrit tel quel dans vos journaux : il contient des adresses courriel, qui sont des renseignements personnels.