Échec de vérification de signature webhook ? Trouvez votre cause
Une erreur webhook signature verification failed ne veut dire qu’une chose : le digest calculé par votre code n’est pas égal au digest transporté dans l’en-tête de la requête. Le message ne dit rien de plus. Il ne parle pas de permissions, pas d’expiration, et il ne s’agit presque jamais d’un bug dans le SDK du fournisseur. Quelque chose diffère entre les octets que le fournisseur a hachés et ceux que vous avez hachés.
Quatre entrées décident du résultat : quels octets ont été signés, quels octets de clé ont servi, quel algorithme de hachage a tourné, et dans quel encodage textuel vous avez comparé. Trompez-vous sur une seule et l’échec a rigoureusement la même allure. L’erreur ne porte aucun indice sur laquelle c’était ; le travail consiste donc à réduire l’espace des entrées, pas à relire le message plus attentivement.
Choisissez une branche de départ :
La signature ne correspond pas ? Trois branches :
├─ Votre framework a-t-il parsé le JSON avant vous ? → section 3
├─ La valeur d'en-tête porte un préfixe, ou ressemble à du base64 ? → section 4
└─ L'en-tête du fournisseur contient-il un horodatage ? → section 2
Chaque section se termine par quelque chose que vous pouvez exécuter sur votre propre payload.
1. Ce que dit une signature qui ne correspond pas
La vérification est une comparaison de deux chaînes d’octets. Quand elle échoue, exactement une chose parmi quatre est fausse, et les quatre sont indépendantes les unes des autres.
Quels octets ont été signés. Le fournisseur a haché une séquence d’octets précise. Peut-être le corps de la requête seul, peut-être un horodatage collé devant le corps. Si votre framework a parsé le JSON et vous a tendu un objet, vous n’avez plus ces octets et vous ne pouvez pas les reconstituer de façon fiable. C’est l’objet de la section 3, et c’est de très loin la cause la plus fréquente.
Quels octets de clé ont servi. La même chaîne de secret peut être interprétée comme du texte UTF-8, comme de l’hexadécimal ou comme du base64, et chaque lecture produit une clé différente. Un secret auquel le chargeur de configuration a laissé un retour à la ligne en trop aussi. Un second échec se cache dans cette dimension : le secret peut être le mauvais secret tout court, et non une mauvaise lecture du bon, ce qui fait l’objet de la section 6.
Dans quel encodage vous avez comparé. Un digest, c’est 32 octets bruts pour SHA-256. L’hexadécimal et le base64 sont deux façons d’écrire ces mêmes octets sous forme de texte, et elles ne se ressemblent jamais. Comparez l’une à l’autre et vous obtenez un hmac signature mismatch permanent, alors même que les octets sous-jacents concordent.
Quel algorithme de hachage a tourné. La plupart des fournisseurs utilisent SHA-256 et le documentent, cette dimension ne vous coûte donc généralement rien. GitHub est l’exception à connaître : chaque livraison transporte X-Hub-Signature (HMAC-SHA1) à côté de X-Hub-Signature-256 (HMAC-SHA256), et la documentation de GitHub précise que l’en-tête SHA-1 « n’est inclus qu’à des fins de compatibilité avec l’existant » tout en recommandant la variante 256. Lisez le mauvais et la longueur vous trahit avant les octets. Le corps de la section 2, signé avec le même secret sous SHA-1, donne sha1=ba2954d180839d8170b08b32cd38483775aaae96, soit 40 caractères hexadécimaux contre les 64 de son digest SHA-256.
Gardez ces quatre dimensions séparées pendant le diagnostic. Le moyen le plus rapide d’en isoler une est de calculer le digest en dehors de votre application, à partir d’entrées que vous maîtrisez : collez un corps et un secret dans le générateur HMAC et regardez ce qui sort. Tout se passe dans votre navigateur et le secret ne quitte pas la page, vous pouvez donc y coller sans risque un secret de signature de production. HMAC fait tourner la même primitive SHA-256 qu’un simple hash SHA-256, à ceci près qu’elle est paramétrée par votre secret ; si vous arrivez à reproduire à la main la valeur du fournisseur, la cryptographie va bien et le bug se trouve dans votre traitement de la requête.
2. Ce que signent réellement les quatre grands fournisseurs
L’hypothèse qui coule le plus d’intégrations, c’est que tout fournisseur signe le corps de la requête et rien d’autre. Deux des quatre plus gros ne le font pas. Voici ce que chacun hache réellement, vérifié dans la documentation de chaque fournisseur :
| Fournisseur | En-tête | Chaîne signée | Encodage | Préfixe de valeur | Secret | Tolérance d’horodatage |
|---|---|---|---|---|---|---|
| Stripe | Stripe-Signature | {timestamp} + . + rawBody | hex | t=…,v1=…,v0=… | secret de signature de l’endpoint (préfixe whsec_) | 5 minutes (300 secondes) |
| GitHub | X-Hub-Signature-256 | rawBody (aucun préfixe) | hex | sha256= | jeton secret du webhook | aucune (pas d’horodatage envoyé) |
| Slack | X-Slack-Signature + X-Slack-Request-Timestamp | v0: + {timestamp} + : + rawBody | hex | v0= | signing secret | 5 minutes |
| Shopify | X-Shopify-Hmac-SHA256 | rawBody | base64 | aucun | client secret de l’application (et non un secret de webhook distinct) | aucune |
Ces quatre cas couvrent, par chance, trois axes orthogonaux. La chaîne signée est soit le corps seul, soit une concaténation avec un horodatage, et même le séparateur diffère : Stripe utilise . là où Slack utilise :. L’encodage est hexadécimal pour trois d’entre eux et base64 pour un. Le secret provient d’un identifiant de webhook dédié pour trois, et du client secret de l’application pour Shopify. C’est le détail que l’on rate le plus souvent, parce qu’il existe dans l’interface d’administration un champ étiqueté « webhook » qui n’est pas celui que vous voulez. Le mécanisme ne s’arrête pas à ces quatre-là : côté paiements européens, Adyen signe lui aussi ses notifications de webhook en HMAC, et le raisonnement de cette section s’y applique, à condition d’aller lire dans sa documentation quelle chaîne exacte il signe.
Pour rendre les différences concrètes, voici un même corps signé de quatre façons avec un même secret :
body : {"id":42,"event":"user.created"}
secret : whsec_test_secret
ts : 1700000000
| Forme | Valeur |
|---|---|
| Style GitHub | sha256=09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b |
| Style Shopify | Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs= |
| Style Stripe | t=1700000000,v1=4b56de5a58122bab8ebbadbed663fbc17d810096d57498f5b24a72f5123b2375 |
| Style Slack | v0=3faf37337484c62dcd1a6c1ff308d1345c31291e4aac9b44554a99e8e35a1f9c |
Lisez les deux premières lignes ensemble : c’est le même digest de 32 octets écrit deux fois. Soixante-quatre caractères hexadécimaux, ou quarante-quatre caractères base64 padding compris. Rien dans ces deux chaînes ne laisse deviner qu’elles sont égales, et c’est pourquoi une comparaison d’un encodage à l’autre produit un écart qui survit à tous les contrôles « mais le secret est bon » que vous pourrez imaginer.
Les deux dernières lignes démontrent l’autre moitié de l’argument. Même corps, même secret, même algorithme, et aucun des deux digests ne ressemble à celui de GitHub, parce que la chaîne hachée commence désormais par un horodatage. La plupart des signalements d’erreur stripe webhook signature verification failed se ramènent à cette ligne : le code a haché le corps tout seul et n’a jamais préfixé la valeur t ni le point. Reproduisez les quatre valeurs dans le générateur HMAC en modifiant seulement le champ message et en changeant le format de sortie, et le mécanisme cesse d’être abstrait.
Une conséquence pratique de la colonne horodatage : un digest Stripe ou Slack n’est valide que quelques minutes, vous ne pouvez donc pas capturer une signature aujourd’hui pour la rejouer dans un test demain. Les signatures GitHub et Shopify sont stables indéfiniment. Elles sont donc beaucoup plus faciles à déboguer, et la protection contre le rejeu vous revient entièrement.
3. Le problème du corps brut
La plupart des signalements webhook signature verification failed viennent de là : le corps que votre code hache n’est plus celui qui est arrivé sur le réseau.
Votre framework a déjà détruit les octets
Les frameworks web sont conçus pour vous épargner le parsing. C’est ce confort qui casse la vérification de signature : au moment où votre handler s’exécute, les octets d’origine ont disparu.
express.json() lit le flux de la requête, le parse et remplace req.body par un objet JavaScript. Le flux est consommé et ne peut plus être relu. Dans FastAPI, déclarer un modèle Pydantic ou un paramètre de corps dict signifie que le framework lit et parse avant même d’entrer dans votre fonction. Rails remplit params depuis le corps JSON via un middleware qui tourne avant votre action de contrôleur. Le convertisseur Jackson de Spring transforme le corps en votre classe DTO, et par défaut le flux d’entrée HttpServletRequest sous-jacent ne peut être lu qu’une seule fois.
Rien de tout cela n’est un bug. Chacun de ces composants fait ce pour quoi il a été configuré. Le problème, c’est qu’une signature porte sur des octets, qu’un objet n’est pas des octets, et que retransformer l’objet en octets est une opération différente de celle qu’a effectuée le fournisseur.
Pourquoi la ré-sérialisation marche parfois
Le conseil habituel dit que ré-sérialiser change les octets. C’est incomplet : parfois, ça ne change rien du tout, et c’est cette moitié manquante qui rend la panne difficile à diagnostiquer.
Voici JSON.stringify(JSON.parse(body)) === body mesuré sur différentes formes de payload :
| Forme du payload | Octets après aller-retour | Changement |
|---|---|---|
{"id":42,"event":"user.created"} | identiques | aucun, et c’est pour ça que les tests locaux passent |
{"amount":1.0} | changés | → {"amount":1} |
{"n":1e3} | changés | → {"n":1000} |
{"id":12345678901234567890} | changés | → {"id":12345678901234567000} (précision perdue) |
{"name":"caf\u00e9"} | changés | → {"name":"café"} (6 octets deviennent 2) |
{"a":1}\n | changés | retour à la ligne final avalé |
{ "a" : 1 } | changés | espaces intérieurs avalés |
{"v":-0.0} | changés | → {"v":0} |
{"p":0.1000000000000000055511151231257827} | changés | → {"p":0.1} |
Regardez la première ligne. Un objet plat avec un entier et une courte chaîne ASCII fait l’aller-retour octet pour octet ; un vérificateur qui parse puis re-sérialise passe donc tous les tests que vous avez écrits contre une fixture de ce genre. Puis vous déployez, et le premier payload qui transporte un montant de 1.0, un identifiant au-delà de 2^53 ou un nom de client accentué échoue. Pas tous les événements, seulement ceux-là.
C’est le mécanisme derrière « ça marche en local, 401 intermittent en production », et c’est nettement pire qu’un vérificateur qui échoue tout le temps. Un vérificateur qui échoue toujours est corrigé en une heure. Un vérificateur qui échoue sur 3 % des événements est mis sur le dos du fournisseur, rejoué, escaladé, puis supporté pendant des semaines. Si votre taux d’échec se situe strictement entre zéro et cent pour cent, c’est ce tableau qu’il faut regarder d’abord.
L’ordre des clés est la cause à laquelle tout le monde pense, et la moins probable en pratique, car JSON.parse conserve l’ordre d’insertion pour les clés de type chaîne. Les vrais coupables sont les nombres et les espaces.
Récupérer le corps brut dans chaque framework
Express, avec le parseur spécifique à la route enregistré avant le parseur JSON global :
const express = require('express');
const crypto = require('crypto');
const app = express();
// Cette route doit être enregistrée AVANT app.use(express.json()).
// body-parser marque la requête comme parsée : un raw() ultérieur renvoie {} sans rien dire.
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body; // un Buffer, pas un objet
const digest = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(raw) // hacher le Buffer directement, sans toString()
.digest('hex');
console.log('bytes:', raw.length, 'digest:', digest);
res.sendStatus(200);
});
app.use(express.json()); // toutes les autres routes reçoivent toujours du JSON parsé
app.listen(3000);
Si vous ne pouvez pas réordonner les middlewares, conservez plutôt une copie pendant le parsing :
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = Buffer.from(buf); },
}));
FastAPI. Starlette met le corps en cache, donc await request.body() renvoie les octets d’origine, même dans un handler qui reçoit par ailleurs un modèle parsé :
import hashlib, hmac, os
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/webhooks/github")
async def github(request: Request):
raw = await request.body() # des octets, exactement tels que reçus
expected = "sha256=" + hmac.new(
os.environ["WEBHOOK_SECRET"].encode("utf-8"), raw, hashlib.sha256
).hexdigest()
received = request.headers.get("X-Hub-Signature-256", "")
if not hmac.compare_digest(expected, received):
raise HTTPException(status_code=401, detail="bad signature")
return {"ok": True}
Rails, où request.raw_post vous donne le corps non parsé sous forme de chaîne :
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def shopify
raw = request.raw_post
digest = Base64.strict_encode64(
OpenSSL::HMAC.digest('sha256', ENV['SHOPIFY_CLIENT_SECRET'], raw)
)
unless OpenSSL.secure_compare(digest, request.headers['X-Shopify-Hmac-SHA256'].to_s)
return head :unauthorized
end
head :ok
end
end
Go, où vous lisez le corps vous-même et devez vous rappeler qu’il est vidé ensuite :
func handler(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET")))
mac.Write(raw)
expected := mac.Sum(nil)
got, err := hex.DecodeString(
strings.TrimPrefix(r.Header.Get("X-Hub-Signature-256"), "sha256="))
if err != nil || !hmac.Equal(expected, got) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// Désérialiser depuis raw, jamais depuis r.Body — il n'y reste plus un octet.
w.WriteHeader(http.StatusOK)
}
Spring, où demander un byte[] court-circuite entièrement Jackson :
@PostMapping(path = "/webhooks/github", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> github(@RequestBody byte[] payload,
@RequestHeader("X-Hub-Signature-256") String header)
throws GeneralSecurityException {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(payload));
boolean ok = MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
header.getBytes(StandardCharsets.UTF_8));
return ok ? ResponseEntity.ok().build()
: ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
ContentCachingRequestWrapper est l’alternative quand c’est un filtre qui doit faire le contrôle et que vous ne pouvez pas changer la signature du contrôleur. Il a son propre piège : getContentAsByteArray() ne renvoie des octets qu’après que quelque chose en aval a lu le flux ; l’appeler avant chain.doFilter(...) vous rend un tableau vide.
4. Encodages qui ne correspondent pas : hex, base64 et la clé elle-même
Trois décisions d’encodage distinctes se glissent entre votre digest et la valeur d’en-tête, et chacune peut casser la comparaison à elle seule.
L’encodage du digest. La sortie de HMAC-SHA256 fait 32 octets. Écrite en hexadécimal minuscule, elle occupe 64 caractères ; écrite en base64 standard, 44 en comptant le = de padding. C’est ce que montrent les deux lignes de la section 2 :
| Encodage | Caractères | Les mêmes 32 octets écrits comme |
|---|---|---|
| hex | 64 | 09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b |
| base64 | 44 | Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs= |
Une heuristique rapide quand vous tombez sur un en-tête inconnu : si la valeur fait 64 caractères pris dans 0-9a-f, c’est de l’hexadécimal. Si elle fait 44 caractères terminés par =, ou qu’elle contient +, / ou des majuscules, c’est du base64. Pour confirmer au lieu de deviner, passez la valeur base64 dans le décodeur Base64 et vérifiez qu’elle rend bien 32 octets ; si c’est le cas, les deux chaînes décrivent le même digest et vous comparez des formats de texte, pas des signatures.
Le préfixe de valeur. GitHub envoie sha256= devant l’hexadécimal. Slack envoie v0=. Stripe enveloppe le tout dans une liste de paires key=value séparées par des virgules. Aucun de ces caractères ne fait partie du digest : soit vous retirez le préfixe de l’en-tête, soit vous l’ajoutez à votre propre valeur. Ne faire ni l’un ni l’autre est la raison numéro un pour laquelle une implémentation correcte signale un hmac signature mismatch. Sous Node, elle ne signale même pas un écart, comme l’explique la section 7.
L’encodage de la clé. Le secret est lui aussi une suite d’octets, et la même chaîne lue en UTF-8, en hexadécimal ou en base64 donne trois clés différentes. Les fournisseurs qui vous remettent un jeton textuel comme whsec_... attendent de l’UTF-8, mais beaucoup de systèmes internes distribuent des secrets base64 ou hexadécimaux qu’il faut décoder avant de signer. Ce mode de défaillance a exactement la même forme que sa version JWT, traitée en détail dans JWT « invalid signature » : toutes les causes et comment les corriger, qui explique notamment comment savoir si un secret donné est du base64 ou du texte brut.
5. Horodatage, tolérance et fenêtres de rejeu
Vous pouvez calculer un digest parfaitement conforme et être rejeté quand même. Les fournisseurs qui joignent un horodatage attendent que vous le contrôliez, et un horodatage périmé, c’est une signature valide que vous devez tout de même refuser.
| Fournisseur | Où se trouve l’horodatage | Fenêtre |
|---|---|---|
| Stripe | t= à l’intérieur de Stripe-Signature | 5 minutes (300 secondes) |
| Slack | en-tête X-Slack-Request-Timestamp | 5 minutes |
| GitHub | non envoyé | sans objet |
| Shopify | non envoyé | sans objet |
Se tromper de fenêtre fait mal dans les deux sens. Trop généreuse, et une requête capturée reste rejouable aussi longtemps que vous l’autorisez, si bien que le contrôle de l’horodatage ne sert plus à rien. Trop serrée, et une simple dérive d’horloge se met à rejeter de vraies livraisons. Cinq minutes est le choix des deux fournisseurs, et le recopier est un défaut raisonnable.
Avant d’élargir une tolérance, vérifiez l’horloge. Les images de conteneurs ne font pas tourner NTP, et une VM reprise depuis un snapshot peut avoir plusieurs minutes de retard sur l’heure réelle sans que rien dans les logs ne le signale. Un hôte qui dérive régulièrement produit des échecs d’abord occasionnels puis totaux. Cela se lit comme une régression de code alors que le code n’a pas bougé.
L’autre bug d’horloge est une unité qui ne correspond pas. Tous les fournisseurs du tableau envoient des secondes epoch. Comparez-en une à une valeur en millisecondes comme le Date.now() de JavaScript et l’écart vaut environ mille fois l’âge réel : chaque événement tombe donc hors de toute fenêtre plausible. Le symptôme est un contrôle de tolérance qui rejette cent pour cent des livraisons alors que le digest, lui, correspond. Si vous ne savez pas quelle unité vous tenez, la longueur vous le dit, et secondes epoch contre millisecondes couvre les conversions ainsi que les pièges de fuseau horaire qui les entourent.
Utilisez la chaîne d’horodatage brute de l’en-tête pour construire la chaîne signée, pas un nombre parsé puis reformaté. Parser 1700000000 en flottant puis le réimprimer peut donner 1700000000.0, et c’est une autre séquence d’octets.
6. Mauvais secret, et secrets qui tournent
Avant d’aller plus loin dans les encodages, écartez la cause la plus banale : le secret n’est peut-être pas le bon. La documentation de Stripe est explicite, « Stripe génère une clé secrète unique pour chaque endpoint », et si vous faites pointer la même URL sur les clés de test et sur les clés live, « le secret est différent pour chacune ». Trois versions de la même erreur en découlent.
Le mode test et le mode live détiennent des secrets distincts : une valeur copiée pendant que le tableau de bord était en mode test échoue sur toutes les livraisons live. Chaque endpoint détient le sien, et la documentation ajoute que « si vous utilisez plusieurs endpoints, vous devez obtenir un secret pour chacun de ceux dont vous voulez vérifier les signatures ». Faites pointer deux endpoints vers un même handler avec un seul secret dans l’environnement et la moitié de votre trafic échoue. Et stripe listen affiche un secret de signature pour le transfert local de la CLI, qui est un endpoint distinct de tout ce qui est enregistré dans le tableau de bord : les deux ne sont pas interchangeables.
Vu de l’extérieur, rien de tout cela ne ressemble à un bug d’encodage. Le digest est bien formé, la comparaison est correcte, et la valeur dans votre environnement est un vrai secret Stripe, simplement pas celui qui a signé cette livraison.
La rotation, c’est cette même dimension qui bouge sous vos pieds. C’est le cas qui ressemble le moins à un problème d’encodage et celui que l’on prend le plus souvent pour un bug de code. Rien n’a changé dans votre code, la vérification marchait hier, et voilà qu’une fraction des événements échoue.
La fenêtre de recouvrement est voulue. Stripe garde l’ancien secret d’endpoint valide jusqu’à 24 heures après la rotation, et pendant cette période l’en-tête Stripe-Signature transporte une signature v1 par secret actif. Shopify fait l’inverse : après une rotation, il peut s’écouler jusqu’à une heure avant qu’il ne commence à utiliser le nouveau secret pour calculer les digests, c’est donc l’ancien qu’il vous faut entre-temps.
C’est le comportement de Stripe qui casse le code, parce que l’en-tête a l’air de ne contenir qu’une signature. Découper sur , et prendre le premier v1 rencontré fonctionne parfaitement jusqu’au jour où il y en a deux ; à partir de là, vous tombez juste une fois sur deux environ, selon le secret qui a signé tel ou tel événement. Parcourez-les toutes :
const crypto = require('crypto');
function verifyStripe(header, rawBody, secret, toleranceSec = 300) {
let t = null;
const v1 = [];
for (const pair of header.split(',')) {
const idx = pair.indexOf('=');
const key = pair.slice(0, idx);
const value = pair.slice(idx + 1);
if (key === 'v1') v1.push(value);
else if (key === 't') t = value; // conserver la chaîne d'origine
}
if (t === null || v1.length === 0) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (!Number.isFinite(age) || age > toleranceSec) return false;
const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest();
return v1.some((sig) => {
const received = Buffer.from(sig, 'hex');
return received.length === expected.length &&
crypto.timingSafeEqual(received, expected);
});
}
Deux détails comptent, au-delà de la boucle. L’horodatage entre dans le payload signé sous la forme de la chaîne telle qu’elle est arrivée. Et le corps est concaténé en octets au lieu de passer par une interpolation de template, qui l’aurait d’abord décodé en UTF-8.
Le même schéma s’applique quand c’est vous qui faites la rotation : acceptez l’ancien et le nouveau secret pendant toute la durée du recouvrement, puis abandonnez l’ancien. Le secret vers lequel vous basculez a besoin d’une entropie pleine : générez-le au lieu de le taper, avec par exemple le générateur de secret de signature pour une valeur aléatoire de 256 bits.
7. Comparer des signatures sans fuiter de temps
Une fois que vous tenez deux digests, la façon de les comparer est une décision de sécurité. L’égalité de chaînes rend la main dès qu’elle trouve un octet différent : le temps qu’elle prend révèle donc combien d’octets de tête étaient corrects. Un attaquant capable d’envoyer beaucoup de requêtes s’en sert pour reconstituer une signature valide octet par octet. C’est lent et bruyant à travers Internet, et parfaitement praticable sur un réseau local.
Tous les runtimes fournissent une comparaison à temps constant :
| Langage | Comparaison à temps constant | Quand les longueurs diffèrent |
|---|---|---|
| Node | crypto.timingSafeEqual(a, b) | lève une exception |
| Python | hmac.compare_digest(a, b) | renvoie False |
| Go | hmac.Equal(a, b) | renvoie false |
| PHP | hash_equals($known, $user) | renvoie false |
| Ruby | OpenSSL.secure_compare(a, b) | renvoie false |
C’est de cette dernière colonne que vient toute une catégorie d’incidents déroutants. Node est l’exception, et il n’échoue pas poliment :
RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length
Un digest SHA-256 en hexadécimal fait 64 caractères. La valeur de X-Hub-Signature-256 en fait 71, parce que sha256= compte sept caractères. Oubliez de retirer le préfixe et les deux buffers n’ont pas la même longueur : timingSafeEqual lève une exception au lieu de renvoyer false. Non attrapée, cette exception remonte hors de votre handler et Express la transforme en 500.
Vous cherchez une réponse webhook 401 unauthorized et vous récupérez une erreur serveur, alors vous allez relire votre handler et votre dispatcher d’événements. Le vrai bug est une ligne au-dessus de la comparaison. Comparer un digest hexadécimal de 64 caractères à un digest base64 de 44 caractères lève la même exception pour la même raison : sous Node, un encodage qui ne correspond pas remonte lui aussi en 500 plutôt qu’en refus propre.
Le correctif consiste à contrôler la longueur vous-même et à renvoyer false :
function safeEqualHex(receivedHex, expectedHex) {
const a = Buffer.from(receivedHex, 'hex');
const b = Buffer.from(expectedHex, 'hex');
if (a.length !== b.length) return false; // garde-fou avant l'appel
return crypto.timingSafeEqual(a, b);
}
Laisser fuiter la longueur est sans danger : la longueur d’un digest est fixée par l’algorithme et publique. Ce qu’il ne faut pas laisser fuiter, c’est quel préfixe correspondait. L’onglet Vérifier du générateur HMAC ne sort pas par anticipation : il replie l’écart de longueur dans le même accumulateur à temps constant, si bien qu’une différence de longueur revient sous la forme d’un simple false et non d’une exception, et vous pouvez confronter une valeur d’en-tête à votre digest calculé sans écrire de code jetable.
8. Quand la couche de transport a modifié vos octets
Vous avez écarté la chaîne signée, le corps brut, les encodages, l’horloge et la rotation. Reste la possibilité que les octets arrivant dans votre processus ne soient pas ceux qui ont quitté le fournisseur.
Compression. Un fournisseur ou un proxy peut envoyer le corps gzippé avec Content-Encoding: gzip. La signature porte sur le payload non compressé : vous devez donc hacher après décompression. Certains frameworks décompressent de façon transparente, d’autres vous remettent les octets compressés, et un corps qui ressemble à du charabia binaire dans vos logs est le signe qui trahit.
Transfert chunked. Avec Transfer-Encoding: chunked, il n’y a pas de Content-Length, et le code qui se fie à cet en-tête pour dimensionner un tampon de lecture tronque le corps. Le digest d’un corps tronqué est un non-sens valide : il ne correspondra jamais, et rien n’a l’air anormal.
Proxies et WAF. Toute couche qui lit et réécrit le corps peut le modifier. AWS API Gateway peut encoder le corps en base64 avant qu’il n’atteigne une Lambda : il faut donc décoder avant de hacher. Des load balancers applicatifs et des service meshes normalisent ou ré-encodent parfois les payloads au passage. Testez en comparant la longueur en octets que voit votre handler au Content-Length envoyé par le fournisseur.
Encodage de caractères et BOM. Les payloads peuvent contenir des caractères non ASCII, et la documentation de GitHub est explicite : le payload doit être traité comme de l’UTF-8. Décoder le corps en chaîne avec le mauvais charset puis le ré-encoder détruit tous les caractères multi-octets. Une marque d’ordre des octets UTF-8, EF BB BF, ajoutée en tête par un éditeur ou un sérialiseur bien intentionné, ajoute trois octets qui n’ont jamais été signés.
Fins de ligne et espaces égarés. Un corps qui a franchi une frontière de fichier en mode texte peut arriver avec les LF réécrits en CRLF. Lisez aussi la spécification du fournisseur pour connaître la chaîne de signature exacte : certains y ajoutent un caractère de leur cru, et Typeform est un cas documenté de retour à la ligne final faisant partie de ce qui est haché. Quand la documentation d’un fournisseur mentionne un caractère supplémentaire, prenez-la au mot.
9. Une procédure de dépannage reproductible
Exécutez ces étapes dans l’ordre. Chacune trouve le bug ou élimine une branche ; vous devriez pouvoir vous arrêter avant la fin.
- Journalisez les octets bruts avant l’exécution de tout middleware. Écrivez le corps dans un fichier, ou journalisez sa longueur en octets plus son SHA-256, depuis le point le plus précoce du cycle de vie de la requête que vous puissiez atteindre. La longueur à elle seule règle un nombre surprenant de cas : une valeur supérieure d’une unité à celle attendue, c’est un retour à la ligne final ; supérieure de trois, c’est un BOM.
- Calculez le digest à la main. Collez ces octets exacts et votre secret dans le générateur HMAC, choisissez SHA-256, et réglez le format de sortie pour qu’il corresponde à l’en-tête. C’est l’étape la plus rentable, parce qu’elle coupe le problème en deux.
- Comparez la valeur calculée à la main avec l’en-tête. Égales : les octets et le secret sont tous les deux corrects et le bug se trouve quelque part dans votre chemin de code, allez donc relire votre comparaison. Différentes : l’une des entrées est fausse, continuez.
- Confrontez la chaîne signée au tableau de la section 2. Ce fournisseur préfixe-t-il un horodatage ? Avec quel séparateur ? Ajoutez le préfixe dans l’outil et recalculez.
- Changez l’encodage du digest. Recalculez en hexadécimal puis en base64 et comparez les deux à l’en-tête. Une valeur d’en-tête de 44 caractères terminée par un
=est du base64, quoi qu’ait supposé votre code. - Changez l’encodage de la clé. Essayez le secret en texte, puis en hexadécimal, puis en base64. L’un des trois produit généralement une correspondance, et cela vous dit ce que le fournisseur attend.
- Vérifiez l’horloge et l’état de rotation. Comparez l’heure de votre serveur à une source connue, confirmez que vous manipulez des secondes epoch, et regardez dans le tableau de bord du fournisseur s’il y a eu une rotation dans les dernières 24 heures.
Deux habitudes accélèrent beaucoup cette boucle. D’abord, capturez un payload en échec et travaillez dessus hors ligne plutôt que d’attendre la livraison suivante. Ensuite, rejouez ce corps capturé contre votre endpoint avec une signature figée, pour que l’entrée ne varie jamais d’une tentative à l’autre. Le générateur de commande cURL assemble la requête avec les en-têtes exacts et un corps lu depuis un fichier : les octets restent donc identiques d’une exécution à l’autre. Reproduire la panne à la demande, c’est ce qui transforme un signalement webhook signature verification failed intermittent en correctif de cinq minutes.
S’il vous faut malgré tout ouvrir un ticket au support, indiquez la longueur en octets du corps que vous avez haché, la valeur d’en-tête mot pour mot, la construction de chaîne signée que vous avez employée, et l’encodage du digest. N’incluez jamais le secret lui-même.
FAQ
Pourquoi ma signature de webhook fonctionne-t-elle en local et échoue-t-elle en production ?
Votre payload de test survit probablement à un aller-retour JSON sans changer, la ré-sérialisation est donc inoffensive. Les vrais payloads contiennent des flottants, de grands entiers, des échappements Unicode ou des espaces supplémentaires, et ceux-là changent bien les octets. Signez le corps brut plutôt qu’une copie ré-sérialisée ; le tableau de la section 3 montre quelles formes cassent.
Faut-il inclure le préfixe sha256= quand on compare des signatures ?
Retirez-le, ou ajoutez-le à votre propre valeur pour que les deux chaînes correspondent exactement. Votre digest hexadécimal calculé fait 64 caractères, la valeur d’en-tête en fait 71 avec le préfixe. Certaines fonctions de comparaison renvoient false quand les longueurs diffèrent, et le timingSafeEqual de Node lève une exception au lieu de renvoyer false.
Peut-on vérifier la signature après que le framework a parsé le JSON ?
Pas de façon fiable. La ré-sérialisation ne reproduit les octets d’origine que pour les payloads sans flottants, sans entiers au-delà de 2^53, sans échappements Unicode et sans espaces superflus. Dès que l’un d’eux apparaît, le digest change : la vérification passe donc en test et échoue sur une fraction des événements de production.
Pourquoi Stripe et GitHub produisent-ils des signatures différentes pour le même payload ?
Parce qu’ils hachent des chaînes différentes. GitHub signe le corps brut seul. Stripe signe l’horodatage, un . littéral, puis le corps, si bien qu’un même payload livré à deux instants différents donne deux digests différents. Slack préfixe v0: et son propre horodatage. Même algorithme, entrée différente.
Quelle durée de tolérance retenir pour l’horodatage ?
Cinq minutes est la valeur retenue par Stripe et Slack, et la recopier est un choix par défaut raisonnable. Des fenêtres plus courtes rejettent des livraisons légitimes dès que l’horloge de votre serveur dérive. Des fenêtres plus longues élargissent la période pendant laquelle une requête capturée peut être rejouée. Synchronisez les horloges avec NTP avant de relâcher la tolérance.
timingSafeEqual renvoie-t-il false quand les longueurs diffèrent ?
Non. Node lève RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length. Non attrapée, cette exception devient un 500 au lieu d’un 401, ce qui vous envoie déboguer votre handler plutôt que la ligne juste au-dessus de la comparaison. Comparez d’abord les longueurs et renvoyez false vous-même.
Mon fournisseur a fait tourner le secret, alors pourquoi certains webhooks échouent-ils encore ?
Les fenêtres de rotation se recouvrent. Stripe garde l’ancien secret valide jusqu’à 24 heures et envoie une signature v1 par secret actif, si bien qu’un code qui ne lit que le premier v1 échoue sur la moitié des événements environ. Shopify peut mettre jusqu’à une heure avant de commencer à utiliser le nouveau secret.
Conclusion
La vérification est une comparaison d’octets : une erreur webhook signature verification failed se ramène donc toujours à un désaccord sur des octets, jamais à quelque chose de cryptographique. Gardez les entrées séparées pendant le diagnostic :
- Quels octets ont été signés. Capturez le corps brut avant qu’un parseur y touche, et ne hachez jamais un objet ré-sérialisé : il correspond assez souvent pour passer vos tests, mais pas pour tenir en production.
- Quels octets de clé ont servi. Les lectures en texte, en hexadécimal et en base64 d’un même secret donnent trois clés différentes.
- Dans quel encodage vous avez comparé. L’hexadécimal fait 64 caractères, le base64 en fait 44, et les deux décrivent les mêmes 32 octets.
- Comment vous avez comparé. Contrôlez la longueur, puis utilisez la fonction à temps constant de votre runtime.
- Le reste, à peu près dans cet ordre de probabilité : le préfixe d’horodatage, le préfixe de valeur, la fenêtre de tolérance, le recouvrement de rotation et la couche de transport.
Quand il vous faut une valeur de référence fiable pour comparer, calculez-la en dehors de votre application : collez le corps et le secret dans le générateur HMAC et laissez-le vous dire quel côté a tort.