La plupart des échecs de signature webhook ne sont pas des bugs crypto. Ce sont des bugs de parsing. Quelqu'un a parsé le corps avant de vérifier. Quelqu'un a utilisé hex là où le provider veut base64. Quelqu'un a comparé avec === au lieu d'une comparaison à temps constant. Ce guide condense tous les problèmes de signature qu'on a déjà débogués.
Le modèle commun
Stripe, GitHub, Shopify, Twilio, Slack, Discord : tous font la même chose en habits différents.
- Vous partagez un secret avec le provider.
- Le provider calcule une signature sur le corps (parfois + timestamp, parfois + URL).
- La signature voyage dans un en-tête.
- Votre handler recalcule et rejette si ça ne correspond pas.
Ce qui change : l'algorithme (HMAC SHA-256 le plus souvent ; Discord utilise Ed25519), l'encodage (hex vs base64), ce qui est signé, et quel en-tête porte la signature.
Les quatre bugs qui couvrent presque tout
1. Corps parsé d'abord
Les middlewares JSON consomment les octets bruts. Une fois parsés, votre signature ne correspondra jamais. Lisez le corps brut avant tout parsing — express.raw() uniquement sur la route webhook, await request.text() dans Next.js App Router, @csrf_exempt + request.body avant DRF dans Django.
2. Mauvais encodage
Stripe et GitHub : hex. Shopify : base64. Détail couvert dans le guide Shopify.
3. Comparaison non sécurisée
=== sort à la première différence et fuite l'information octet par octet.
// Node
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
// Python
hmac.compare_digest(a, b)
// Ruby
ActiveSupport::SecurityUtils.secure_compare(a, b)
4. Timestamp non validé
Sans timestamp signé, un attaquant qui capture une requête valide peut la rejouer indéfiniment. Stripe accepte 5 minutes, GitHub aussi. Rejetez tout ce qui dépasse la fenêtre.
Ce que contient vraiment l'en-tête
Stripe envoie t=...,v1=.... GitHub envoie sha256=.... Twilio inclut l'URL dans le payload signé. Lisez la doc du provider et implémentez contre son format exact.
Le test local rend ça débogable
Les bugs de signature sont silencieux et difficiles à reproduire en staging. Un tunnel avec capture vous donne la requête exacte qui a échoué et un bouton de rejeu.
Loggez la longueur du corps, l'en-tête lu, la signature calculée. Ne loggez jamais le secret.
Un vérificateur minimal correct
app.post(
'/webhooks/foo',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-foo-signature'];
const timestamp = req.headers['x-foo-timestamp'];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send('stale');
}
const expected = crypto
.createHmac('sha256', process.env.FOO_SECRET)
.update(`${timestamp}.${req.body}`)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
)) {
return res.status(401).send('invalid');
}
handleEvent(JSON.parse(req.body));
res.status(200).end();
},
);
Si ça échoue encore
Trois choses à vérifier : whitespace dans le secret de l'env, en-tête réécrit par le tunnel (rare), ou test mode vs live mode. On a déjà vu les trois.
Guides provider : Stripe, GitHub, Shopify. Ou si vous êtes face à un 401 : déboguer un 401 webhook. Rejoignez la waitlist PortPreview.