Aller au contenu

Vérification de signature

Prouver qu'une livraison vient bien de Wajub et n'a pas été rejouée.

Votre URL de webhook est publique. Quiconque la trouve peut y poster un payment.succeeded bien formé et vous regarder livrer une commande que personne n'a payée. La signature est ce qui distingue une vraie livraison de cette imitation, et c'est la seule chose qui le fasse.

Deux en-têtes portent la preuve.

En-têteCe que c'est
X-Wajub-Signaturev1= suivi d'un HMAC-SHA256 en hexadécimal
X-Wajub-TimestampSecondes Unix, fait partie de ce qui est signé

Ce qui est signé

Pas le corps seul. L'horodatage et le corps, joints par un seul point.

La chaîne signée
signed  = "{timestamp}.{raw_body}"
digest  = hex(hmac_sha256(signed, endpoint_secret))
header  = "v1=" + digest

L'horodatage est inclus dans le HMAC exprès : une livraison interceptée ne peut pas être rejouée un jour plus tard, car la déplacer dans le temps casse la signature. Comparer l'horodatage à l'heure actuelle constitue la seconde moitié de cette protection, et les SDKs le font pour vous avec une fenêtre de 300 secondes.

Avec un SDK

Le secret appartient au client, pas à l'appel.

# What Wajub sends you
POST /webhooks/wajub HTTP/1.1
X-Wajub-Signature: v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
X-Wajub-Timestamp: 1748083260
X-Wajub-Event: payment.succeeded
X-Wajub-Delivery-Id: whd_7Yh2MpL4tRb3nP8sZcXv
Content-Type: application/json

{"id":"evt_…","event":"payment.succeeded","data":{…}}

Go est la seule exception à la valeur par défaut : son quatrième argument n'en a pas, passez donc 0 et le SDK applique la même fenêtre de 300 secondes.

Sans SDK

Trois étapes, dans cet ordre : vérifier le préfixe, recalculer le digest, comparer en temps constant. Ensuite seulement, regardez l'horodatage.

# Reproduce a signature locally from a captured delivery
TIMESTAMP=1748083260
BODY=$(cat delivery.json)
printf '%s.%s' "$TIMESTAMP" "$BODY" \
  | openssl dgst -sha256 -hmac "$WAJUB_WEBHOOK_SECRET" -hex

Les quatre erreurs classiques

Le corps a été parsé avant d'être hashé

C'est la cause de presque toutes les signatures qui refusent de se vérifier. Votre framework parse le JSON, vous le resérialisez pour le hasher, et les octets reviennent dans un ordre différent ou avec d'autres espaces. Le digest porte sur les octets exacts que nous avons envoyés.

FrameworkQuoi utiliser
Expressexpress.raw({ type: 'application/json' }) sur cette route uniquement
Flaskrequest.get_data(), jamais request.json
Laravel$request->getContent()
Goio.ReadAll(r.Body) avant tout décodeur
Route handler Next.jsawait request.text()

La comparaison a levé une exception au lieu de renvoyer false

crypto.timingSafeEqual de Node lève une RangeError quand les deux buffers n'ont pas la même longueur, et une signature falsifiée a généralement une longueur différente. Sans vérification préalable de la longueur, votre handler plante précisément sur la requête qu'il devait rejeter. hash_equals et compare_digest gèrent ce cas eux-mêmes.

Le type d'événement a été lu dans le mauvais champ

L'horloge a dérivé

La vérification tolère 300 secondes d'écart entre l'horodatage signé et l'horloge de votre serveur. Un conteneur dont l'horloge a dérivé au-delà rejette toutes les livraisons, même avec une signature valide. Si tout s'est mis à échouer d'un coup sans que rien n'ait changé dans votre code, vérifiez NTP avant de vérifier le secret.

Le secret

Chaque endpoint a le sien, qui commence par whsec_, affiché une seule fois à la création de l'endpoint puis consultable dans Konsole. Le renouveler se fait en un seul appel.

Renouveler le secret d'un endpoint
curl -X POST https://api.wajub.com/webhooks/wh_7Yh2MpL4tRb3nP8sZcXv/rotate-secret \
  -H "Authorization: sk.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…"

Le nouveau secret prend effet immédiatement : déployez-le donc avant de le renouveler, pas après.

Que pensez-vous de ce contenu ?