11 min de lectureIngénierie

Vérifier les signatures des webhooks : HMAC-SHA256 avec Node, Python et Go

Comment vérifier une signature de webhook avec HMAC-SHA256 : corps brut, comparaison en temps constant, fenêtre anti-rejeu et rotation du secret, avec du code Node, Python, Go et n8n.

Marius Voß
DevRel · edge infra
Illustration en pixels montrant comment vérifier les en-têtes de signature d'un webhook : un horodatage et un corps brut hachés avec HMAC-SHA256 en une valeur hexadécimale v1=, comparée en temps constant à côté d'une clé en pixels

Pour vérifier une signature de webhook, recalculez un HMAC sur exactement les octets signés par l'émetteur, avec le secret que vous partagez avec lui, puis comparez votre valeur à l'en-tête de signature en temps constant. Vérifiez ensuite que l'horodatage signé est récent. Pour les webhooks Elido, cela signifie utiliser HMAC-SHA256 avec votre secret whsec_... complet comme clé, sur {X-Webhook-Timestamp}.{raw body}, encoder le résultat en hexadécimal, lui ajouter le préfixe v1=, puis le comparer à X-Elido-Signature.

C'est tout l'algorithme. Les échecs viennent des détails qui l'entourent : un analyseur de corps exécuté trop tôt, un secret qui a été décodé ou un condensat hexadécimal comparé à une valeur en base64. Cet article présente la vérification générale des signatures HMAC de webhook, puis du code Elido fonctionnel pour Node, Python, Go et un nœud Code n8n, ainsi que la fenêtre anti-rejeu et la rotation du secret que la plupart des guides de démarrage rapide oublient.

Si vous n'avez pas encore configuré d'endpoint, commencez par les webhooks pour les événements de liens, qui répertorient chaque type d'événement et l'enveloppe de données utiles. Cette page prend le relais lorsqu'une requête signée arrive sur votre serveur.

Fonctionnement des signatures HMAC de webhook

Un endpoint de webhook est une URL publique. Toute personne qui la trouve peut envoyer par POST un corps JSON ressemblant à un véritable événement ; le récepteur a donc besoin d'une preuve d'origine. HMAC la fournit à peu de frais : l'émetteur et le récepteur partagent un secret, l'émetteur calcule HMAC-SHA256(secret, message) et place le résultat dans un en-tête, puis le récepteur effectue le même calcul et compare les valeurs. Sans le secret, personne ne peut produire une valeur correspondante, et la modification d'un seul octet du message change tout le condensat.

Trois détails diffèrent selon les fournisseurs, et chacun fait échouer la vérification s'il est mal géré :

  1. Ce qui entre dans le message. GitHub signe uniquement le corps brut. Stripe et Elido signent un horodatage, un point et le corps. La spécification Standard Webhooks signe un identifiant de message, l'horodatage et le corps.
  2. La manière dont le condensat est encodé. Hexadécimal ou base64, avec un préfixe de schéma comme v1= ou sha256=.
  3. La clé utilisée. Certains fournisseurs décodent le secret en base64 après son préfixe. Elido ne le fait pas : la clé est la chaîne whsec_ complète, convertie en octets UTF-8.

Placer l'horodatage dans le message signé est important. Cela empêche un attaquant d'associer un ancien corps correctement signé à un nouvel en-tête d'horodatage, ce qui rend possible l'application d'une fenêtre anti-rejeu.

Comment vérifier une signature de webhook : Elido assemble l'horodatage Unix, un point et le corps brut, les hache avec HMAC-SHA256 sous le secret whsec_ et envoie de l'hexadécimal v1= dans X-Elido-Signature ; le récepteur recalcule la même valeur à partir des octets bruts et de l'en-tête d'horodatage, puis compare en temps constant

Ce qu'Elido signe et les en-têtes qui le transportent

Chaque livraison vers un endpoint event ou siem est une requête POST avec Content-Type: application/json et un corps de la forme {"type", "workspace_id", "data", "timestamp"}. Les types d'endpoint orientés discussion (Discord, Telegram, Sentry) s'authentifient via leur URL et ne portent aucun en-tête HMAC ; tout ce qui suit s'applique donc uniquement aux deux premiers types.

En-têteValeurQue faire avec
X-Elido-Signaturev1= + 64 caractères hexadécimaux minusculesComparer à votre valeur calculée
X-Webhook-SignatureMême valeur que ci-dessusAncien alias ; lire l'un ou l'autre, pas les deux
X-Webhook-TimestampSecondes Unix, par ex. 1789000000Partie du message signé ; vérifier son ancienneté
X-Elido-Signature-Previousv1= + hexadécimal, signé avec l'ancien secretPrésent uniquement pendant la période de rotation
X-Webhook-EventNom de l'événement, par ex. link.createdRouter l'événement (après vérification)
X-Webhook-DeliveryIdentifiant numérique, stable entre les nouvelles tentativesClé de déduplication pour un traitement idempotent

Le secret est généré pour vous lors de la création de l'endpoint : whsec_ suivi de 64 caractères hexadécimaux. Il est renvoyé une seule fois dans la réponse de création, puis plus jamais, et doit donc être placé directement dans votre gestionnaire de secrets.

Voici un vecteur de test à utiliser avec votre code. Avec le secret whsec_test_only_do_not_use, l'horodatage 1789000000 et le corps {"type":"link.created","workspace_id":42}, la valeur correcte de l'en-tête est :

v1=b9369aa411a8b7ce705bcd5bba112dea9d72d2e787aa88959ff62f33942d1a15

Vous pouvez le reproduire depuis un shell ; c'est ma première vérification chaque fois qu'un récepteur n'est pas d'accord avec l'émetteur :

printf '%s.%s' 1789000000 '{"type":"link.created","workspace_id":42}' \
  | openssl dgst -sha256 -hmac 'whsec_test_only_do_not_use' -r

Un piège à éviter : le champ timestamp du corps correspond à l'heure à laquelle l'événement s'est produit. L'horodatage signé est celui de l'en-tête X-Webhook-Timestamp, défini lors de l'envoi de la requête. Ne les confondez pas.

Valider une signature de webhook dans Node

Avec Express, la correction de la plupart des échecs tient en une ligne : montez express.raw() sur la route du webhook afin que req.body soit un Buffer contenant exactement les octets reçus. Enregistrez cette route avant tout app.use(express.json()) global, car une fois que l'analyseur JSON a consommé le flux, l'analyseur brut n'a plus rien à lire.

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.ELIDO_WEBHOOK_SECRET; // the full whsec_... string
const TOLERANCE_SEC = 300;

function matches(expected, got) {
  const a = Buffer.from(expected);
  const b = Buffer.from(got ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}

const app = express();

app.post(
  "/webhooks/elido",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ts = req.get("X-Webhook-Timestamp") ?? "";
    if (
      !/^\d+$/.test(ts) ||
      Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SEC
    ) {
      return res.status(400).send("stale or missing timestamp");
    }
    const expected =
      "v1=" +
      createHmac("sha256", SECRET)
        .update(`${ts}.`)
        .update(req.body)
        .digest("hex");
    const ok = [
      req.get("X-Elido-Signature"),
      req.get("X-Elido-Signature-Previous"),
    ].some((got) => matches(expected, got));
    if (!ok) return res.status(401).send("bad signature");

    const event = JSON.parse(req.body.toString("utf8"));
    // enqueue event, then acknowledge fast
    res.sendStatus(200);
  },
);

Le contrôle de longueur n'est pas décoratif. timingSafeEqual lève une exception pour des buffers de longueurs différentes au lieu de renvoyer false. Si vous utilisez le SDK TypeScript du guide de démarrage de l'API et des SDK, webhooks.verify() dans @elido/sdk effectue le même HMAC et la même comparaison en temps constant. Passez toutefois explicitement { maxSkewSec: 300 } : sans cette option, il ne vérifie pas du tout l'ancienneté de l'horodatage.

Vérification HMAC SHA256 d'un webhook en Python et Go

La bibliothèque standard de Python suffit. Avec FastAPI, await request.body() renvoie les octets bruts ; avec Flask, appelez request.get_data() avant que quoi que ce soit n'accède à request.json.

import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, HTTPException, Request

SECRET = os.environ["ELIDO_WEBHOOK_SECRET"].strip().encode()
TOLERANCE = 300
app = FastAPI()


def verify(raw: bytes, ts: str, candidates: list) -> bool:
    if not ts.isdigit() or abs(time.time() - int(ts)) > TOLERANCE:
        return False
    digest = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    expected = "v1=" + digest
    return any(c and hmac.compare_digest(c, expected) for c in candidates)


@app.post("/webhooks/elido")
async def elido_webhook(request: Request):
    raw = await request.body()
    h = request.headers
    sigs = [h.get("x-elido-signature"), h.get("x-elido-signature-previous")]
    if not verify(raw, h.get("x-webhook-timestamp", ""), sigs):
        raise HTTPException(status_code=401, detail="bad signature")
    event = json.loads(raw)
    # enqueue event
    return {"ok": True}

En Go, lisez le corps une seule fois, limitez sa taille et utilisez hmac.Equal de crypto/hmac, qui compare en temps constant. Je construis le message à partir de la chaîne de l'en-tête exactement telle qu'elle a été reçue, plutôt que de reformater l'entier analysé.

package webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"strconv"
	"time"
)

const toleranceSec = 300

// Verify returns the raw body when the request carries a valid Elido signature.
func Verify(w http.ResponseWriter, r *http.Request, secret []byte) ([]byte, bool) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		return nil, false
	}
	tsHeader := r.Header.Get("X-Webhook-Timestamp")
	ts, err := strconv.ParseInt(tsHeader, 10, 64)
	if err != nil {
		return nil, false
	}
	if age := time.Now().Unix() - ts; age > toleranceSec || age < -toleranceSec {
		return nil, false
	}

	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(tsHeader + "."))
	mac.Write(body)
	expected := []byte("v1=" + hex.EncodeToString(mac.Sum(nil)))

	for _, name := range []string{"X-Elido-Signature", "X-Elido-Signature-Previous"} {
		if got := r.Header.Get(name); got != "" && hmac.Equal([]byte(got), expected) {
			return body, true
		}
	}
	return nil, false
}

Les trois versions répondent 401 pour une signature incorrecte et n'analysent le JSON qu'après la réussite du contrôle. Elido considère tout code différent de 2xx comme une tentative échouée. Si votre vérificateur est incorrect, les livraisons authentiques s'accumulent donc en 401 dans le journal des livraisons de l'endpoint. C'est le premier endroit à consulter après un déploiement.

Vous voulez tester avec du trafic réel ? Créez un endpoint dans un espace de travail depuis la page de fonctionnalité des webhooks et dirigez-le vers un tunnel local ; le journal des livraisons affiche le code d'état renvoyé par votre vérificateur pour chaque tentative.

Vérifier la signature du webhook dans un nœud Code n8n

n8n peut effectuer le même contrôle sans service supplémentaire. Activez l'option Raw Body dans le nœud Webhook, qui stocke la requête intacte sous forme de données binaires, puis ajoutez immédiatement après un nœud Code. Le module intégré crypto est autorisé dans le nœud Code n8n, le code suivant fonctionne donc tel quel :

const crypto = require("crypto");
const h = $input.first().json.headers;
const raw = await this.helpers.getBinaryDataBuffer(0, "data");

const ts = h["x-webhook-timestamp"] ?? "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
  throw new Error("stale delivery");
}
const expected = Buffer.from(
  "v1=" +
    crypto
      .createHmac("sha256", $env.ELIDO_WEBHOOK_SECRET)
      .update(`${ts}.`)
      .update(raw)
      .digest("hex"),
);
const ok = ["x-elido-signature", "x-elido-signature-previous"].some((name) => {
  const got = Buffer.from(h[name] ?? "");
  return (
    got.length === expected.length && crypto.timingSafeEqual(got, expected)
  );
});
if (!ok) throw new Error("bad signature");
return [{ json: JSON.parse(raw.toString("utf8")) }];

Une erreur levée arrête l'exécution : rien de ce qui suit ne s'exécute donc pour un événement falsifié. Un piège lié à l'auto-hébergement : si votre instance définit N8N_BLOCK_ENV_ACCESS_IN_NODE=true, le nœud Code ne peut pas lire $env, le secret est récupéré vide et chaque livraison échoue au contrôle. Le guide n8n auto-hébergé couvre la partie reverse proxy, et l'article sur le raccourcisseur d'URL n8n montre quoi construire une fois les événements reçus.

Fenêtres anti-rejeu et rotation du secret

Une signature valide prouve qui a envoyé une requête, mais pas quand. Quelqu'un qui intercepte une livraison signée, dans une ligne de journal ou via un proxy mal configuré, pourrait la renvoyer une semaine plus tard et le HMAC correspondrait toujours. Le contrôle de l'horodatage ferme cette brèche et relève de votre responsabilité : le service de livraison Elido signe l'horodatage, mais n'impose aucune fenêtre de votre côté. J'utilise 300 secondes. Synchronisez l'horloge du récepteur avec NTP, car un serveur décalé de quelques minutes commencera à rejeter du trafic légitime.

Les nouvelles tentatives ne se heurtent pas à la fenêtre anti-rejeu. Chaque tentative reçoit un nouvel X-Webhook-Timestamp et une nouvelle signature, tandis que X-Webhook-Delivery reste identique. Cette séparation fournit les deux défenses : l'horodatage limite la durée d'utilisation d'une requête interceptée et un index unique sur l'identifiant de livraison empêche qu'une nouvelle tentative légitime soit traitée deux fois. L'article sur les limites de débit et l'idempotence présente le même modèle pour la partie API entrante.

La rotation s'effectue via POST /v1/workspaces/{workspace_id}/webhooks/{id}/rotate-secret, ou avec le bouton Rotate sur la page de l'endpoint. La réponse contient une fois le nouveau secret, ainsi que grace_window_days (7) et previous_expires_at. Pendant ces sept jours, chaque livraison porte deux signatures :

  • X-Elido-Signature, créée avec le nouveau secret
  • X-Elido-Signature-Previous, créée avec l'ancien

C'est pourquoi chaque extrait ci-dessus vérifie les deux en-têtes avec le secret qu'il détient. Un récepteur qui utilise encore l'ancien secret correspond au second en-tête ; après le déploiement du nouveau secret, il correspond au premier. Rien n'échoue entre les deux. Considérez toutefois l'en-tête de l'ancienne clé comme un pont plutôt qu'une garantie et déployez le nouveau secret en début de semaine.

Chronologie de la rotation du secret de webhook : avant la rotation, seul X-Elido-Signature est envoyé ; après rotate-secret, pendant une fenêtre de grâce de sept jours, les livraisons portent X-Elido-Signature avec la nouvelle clé et X-Elido-Signature-Previous avec l'ancienne, de sorte qu'un récepteur détenant l'un ou l'autre secret peut vérifier ; après la fenêtre, seule la nouvelle clé signe

Pourquoi la vérification de la signature du webhook échoue

Quand j'aide quelqu'un à déboguer ce problème, c'est presque toujours la même courte liste. Parcourez-la dans l'ordre :

  1. JSON sérialisé à nouveau. JSON.stringify(req.body) ou json.dumps(payload) produit des octets différents de ceux hachés par l'émetteur : ordre des clés, espaces, barres obliques échappées, échappements Unicode. Hachez le corps brut. Si votre framework l'a déjà analysé, corrigez l'ordre des middlewares au lieu d'essayer de reconstruire la chaîne.
  2. Les mauvais octets de clé. Elido utilise la chaîne whsec_... complète comme clé HMAC. Supprimer le préfixe, décoder le reste en hexadécimal ou le décoder en base64 (ce que font les bibliothèques Standard Webhooks) vous donne une clé différente. Un saut de ligne final ajouté par echo dans un fichier de secrets produit le même problème, d'où l'appel à .strip() dans l'exemple Python.
  3. Mauvais encodage. Comparez v1= suivi d'hexadécimal en minuscules à l'en-tête. Un condensat base64, une chaîne hexadécimale en majuscules ou un préfixe manquant ne correspondront jamais.
  4. Mauvais horodatage. Utilisez la chaîne de l'en-tête X-Webhook-Timestamp, pas le champ timestamp du corps ni un nombre que vous avez analysé puis reformaté.

Deux points secondaires : la comparaison avec == fonctionne, mais révèle des informations sur le temps d'exécution, utilisez donc la fonction en temps constant fournie par votre langage ; et un proxy qui décompresse ou réencode les corps fera également échouer la vérification, même si c'est rare avec de simples requêtes POST JSON.

Si les deux côtés ne correspondent toujours pas, journalisez l'horodatage, la longueur du corps et les premiers caractères hexadécimaux de votre condensat, puis exécutez la commande openssl précédente avec les mêmes entrées. Le côté qui correspond à openssl est le bon. Pour savoir où la signature s'insère parmi les autres contrôles à vérifier chez tout fournisseur, consultez la checklist de sécurité d'un raccourcisseur d'URL.

Lisez l'article de référence : les webhooks pour les événements de liens.

Articles associés

Questions fréquentes

Comment vérifier une signature de webhook ?

Recalculez le HMAC sur exactement ce que l'émetteur a signé, avec le secret partagé, puis comparez votre résultat à l'en-tête de signature en temps constant. Pour Elido, cela signifie appliquer HMAC-SHA256 à la valeur X-Webhook-Timestamp, suivie d'un point et du corps brut, encoder le résultat en hexadécimal avec le préfixe v1=, puis le comparer. Rejetez la requête si rien ne correspond ou si l'horodatage est périmé.

Pourquoi la vérification de ma signature de webhook échoue-t-elle sans cesse ?

Presque toujours parce que vous avez haché des octets différents de ceux de l'émetteur. Un analyseur de corps JSON s'est exécuté en premier et vous avez sérialisé à nouveau l'objet, vous avez décodé le secret ou vous avez comparé de l'hexadécimal à du base64. Hachez les octets bruts de la requête, utilisez la chaîne secrète exactement telle qu'elle a été fournie et journalisez les deux valeurs côte à côte.

Qu'est-ce que HMAC dans un webhook ?

HMAC est un hachage utilisant une clé : l'émetteur incorpore dans un hachage SHA-256 du message un secret qu'il partage avec vous. Seule une personne qui détient le secret peut produire une valeur correspondante. Une signature valide prouve donc que la requête vient de l'émetteur et que le corps n'a pas été modifié en transit.

Comment empêcher les attaques par rejeu sur les webhooks ?

Signez l'horodatage avec le corps et rejetez toute requête dont l'horodatage date de plus de quelques minutes ; cinq minutes est une fenêtre courante. Stockez ensuite l'identifiant de livraison et ignorez les identifiants déjà traités. Elido signe l'horodatage, mais laisse le contrôle de fraîcheur à votre récepteur.

Dois-je utiliser de l'hexadécimal ou du base64 pour une signature de webhook HMAC-SHA256 ?

Utilisez ce que l'émetteur documente, car les deux encodages du même condensat ne sont jamais égaux lors de la comparaison. Elido envoie de l'hexadécimal en minuscules après un préfixe v1=. Shopify et la spécification Standard Webhooks utilisent base64, tandis que GitHub utilise de l'hexadécimal après sha256=. Encodez votre condensat de la même manière avant de le comparer.

Comment faire tourner un secret de webhook sans perdre d'événements ?

Utilisez un émetteur qui signe avec les deux clés pendant un certain temps. Après la rotation du secret d'un endpoint Elido, chaque livraison contient X-Elido-Signature avec la nouvelle clé et X-Elido-Signature-Previous avec l'ancienne pendant sept jours. Acceptez l'un ou l'autre en-tête, déployez le nouveau secret et l'ancien expire de lui-même.

Essayer Elido

Collez une URL, obtenez un lien court

Sans inscription. Lien actif 30 jours. Inscrivez-vous pour le garder pour toujours.

Gratuit, sans inscription · 2 par jour

Essayer Elido

Raccourcisseur d'URL hébergé en UE : domaines personnalisés, analyses approfondies et API ouverte. Forfait gratuit - sans carte bancaire.

Tags
verify webhook signature
webhook signature verification
hmac webhook
webhook hmac sha256
webhook replay attack
webhook secret rotation

Lire la suite