Tous les articles
webhook securityHMACsignature verificationbest practices

Vérification de signature webhook, sans mystère

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.

  1. Vous partagez un secret avec le provider.
  2. Le provider calcule une signature sur le corps (parfois + timestamp, parfois + URL).
  3. La signature voyage dans un en-tête.
  4. 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.

Questions fréquentes

Pourquoi ma vérification de signature webhook échoue ?
Le plus souvent le corps a été parsé par un middleware avant la vérification. Autres causes fréquentes : confusion hex/base64, comparaison non sécurisée échouant sur certains cas, ou mauvais secret (test vs live, dashboard vs CLI).
La comparaison à temps constant est-elle vraiment nécessaire ?
Oui. L'égalité simple fuite l'information octet par octet sur le secret. L'attaque est difficile en pratique mais la comparaison sécurisée tient en une ligne — aucune raison de ne pas l'utiliser.
Pourquoi la signature inclut-elle un timestamp ?
Sans timestamp signé, un attaquant qui capture une requête valide peut la rejouer indéfiniment. Le timestamp permet de rejeter tout ce qui dépasse la fenêtre de tolérance du provider, généralement quelques minutes.