12 min de lectureIngénierie

Vérifier une signature de webhook

Vérifier une signature de webhook HMAC-SHA256 : corps brut, comparaison en temps constant, fenêtre anti-rejeu, rotation du secret.

Marius Voß
DevRel · edge infra

Pour vérifier la signature d'un webhook, recalculez un HMAC sur exactement les octets que l'expéditeur a signés, avec le secret que vous partagez avec lui, et 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 HMAC-SHA256 avec votre secret whsec_... entier comme clé, sur {X-Webhook-Timestamp}.{raw body}, encodé en hexadécimal, préfixé par v1=, et comparé à 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é, un condensé hexadécimal comparé à un condensé base64. Cet article couvre la vérification de signature de webhook HMAC en général, puis du code Elido fonctionnel pour Node, Python, Go et un nœud Code n8n, plus la fenêtre anti-rejeu et la rotation du secret que la plupart des guides de démarrage omettent.

Si vous n'avez pas encore configuré d'endpoint, commencez par les webhooks pour les événements de liens, qui liste chaque type d'événement et l'enveloppe de la charge utile. Cette page reprend une fois qu'une requête signée arrive sur votre serveur.

Comment fonctionnent les signatures de webhook HMAC

Un endpoint de webhook est une URL publique. Quiconque la trouve peut envoyer en POST un corps JSON qui ressemble à un vrai événement, le récepteur a donc besoin d'une preuve d'origine. HMAC la donne à bas coût : l'expéditeur et le récepteur partagent un secret, l'expéditeur calcule HMAC-SHA256(secret, message) et place le résultat dans un en-tête, et le récepteur refait le même calcul et compare. Sans le secret, personne ne peut produire une valeur correspondante, et changer un seul octet du message change tout le condensé.

Trois détails diffèrent selon les fournisseurs, et chacun fait échouer la vérification si vous vous trompez :

  1. Ce qui entre dans le message. GitHub signe le corps brut seul. Stripe et Elido signent un horodatage, un point et le corps. La spécification Standard Webhooks signe un ID de message, l'horodatage et le corps.
  2. Comment le condensé est encodé. Hexadécimal ou base64, avec un préfixe de schéma tel que v1= ou sha256=.
  3. Ce qu'est la clé. Certains fournisseurs décodent le secret en base64 après son préfixe. Pas Elido : la clé est la chaîne whsec_ complète en octets UTF-8.

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

Comment vérifier la signature d'un webhook : Elido assemble l'horodatage Unix, un point et le corps brut, le hache avec HMAC-SHA256 sous le secret whsec_ et envoie v1= en hexadécimal dans X-Elido-Signature. Le formulaire d'ajout de point de terminaison webhook d'Elido avec l'URL du point de terminaison, la sélection d'événements et le secret de signature que les destinataires utilisent pour la vérification HMAC

Ce qu'Elido signe et quels en-têtes le portent

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

En-têteValeurQue faire avec
X-Elido-Signaturev1= + 64 caractères hexadécimaux en minusculesComparer à votre valeur calculée
X-Webhook-SignatureMême valeur que ci-dessusAncien alias ; lisez l'un ou l'autre, pas les deux
X-Webhook-TimestampSecondes Unix, p. ex. 1789000000Fait partie du message signé ; vérifiez son âge
X-Elido-Signature-Previousv1= + hex, signé avec l'ancien secretPrésent uniquement pendant une période de grâce de rotation
X-Webhook-EventNom de l'événement, p. ex. link.createdAiguiller l'événement (après vérification)
X-Webhook-DeliveryID de livraison numérique, stable entre les nouvelles tentativesClé de déduplication pour un traitement idempotent

Vous obtenez le secret à la création : whsec_ suivi de 64 caractères hexadécimaux. Il est renvoyé une seule fois dans la réponse de création et plus jamais, il va donc directement dans votre coffre à secrets.

Voici un vecteur de test sur lequel exécuter 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 la reproduire depuis un shell, ce qui est mon premier réflexe chaque fois qu'un récepteur est en désaccord avec l'expéditeur :

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

Un piège ici : le champ timestamp du corps est le moment où l'événement s'est produit, tandis que l'horodatage signé est celui de l'en-tête X-Webhook-Timestamp, fixé à l'envoi de la requête. Ne les confondez pas.

Valider la signature d'un webhook dans Node

Dans Express, la correction de la plupart des échecs tient en une ligne : montez express.raw() sur la route du webhook pour que req.body soit un Buffer des octets exacts reçus. Déclarez 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);
  },
);

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

Vérification HMAC SHA256 de webhook en Python et en 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 ne touche 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, plafonnez 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 que 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 sur une mauvaise signature et n'analysent le JSON qu'une fois la vérification passée. Elido compte tout code non 2xx comme une tentative échouée : si votre vérificateur est faux, les livraisons authentiques s'accumulent en 401 dans le journal de livraisons de l'endpoint. C'est le premier endroit où regarder après un déploiement.

Envie de voir cela sur du trafic réel ? Créez un endpoint dans un espace de travail depuis la page de la fonctionnalité webhooks et pointez-le vers un tunnel local ; le journal de livraisons affiche le code de statut que votre vérificateur a renvoyé pour chaque tentative.

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

n8n peut faire la même vérification sans service supplémentaire. Activez l'option Raw Body dans le nœud Webhook, qui stocke la requête intacte en données binaires, puis ajoutez un nœud Code juste après. Le module crypto intégré est autorisé dans le nœud Code n8n, ce code s'exécute 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 en aval ne s'exécute donc sur un événement falsifié. Un piège en 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 revient vide et chaque livraison échoue à la vérification. Le guide n8n auto-hébergé couvre le côté reverse proxy, et l'article sur le nœud n8n de raccourcisseur d'URL montre quoi construire une fois les événements arrivés.

En quoi Elido diffère de Stripe, GitHub et Standard Webhooks

Si vous avez écrit un vérificateur pour un autre fournisseur, ne le copiez pas aveuglément. Vérifié dans la documentation de chaque éditeur le 2026-10-25 :

  • Stripe place t= et une ou plusieurs valeurs v1= dans un seul en-tête Stripe-Signature, signe timestamp.body avec HMAC-SHA256 en hexadécimal, et ses bibliothèques adoptent par défaut une tolérance de cinq minutes (documentation Stripe). Elido place l'horodatage dans son propre en-tête.
  • GitHub signe le corps brut seul, envoie sha256= plus de l'hexadécimal dans X-Hub-Signature-256, et recommande une comparaison en temps constant (documentation GitHub). Il n'y a aucun horodatage à vérifier.
  • Standard Webhooks utilise webhook-id, webhook-timestamp et webhook-signature, signe id.timestamp.body, encode en base64 avec un préfixe v1, et vous laisse la valeur de tolérance (spécification). Le X-Webhook-Delivery d'Elido joue le rôle de l'ID, mais il ne fait pas partie du message signé.

Le schéma d'Elido est celui de signing.Sign dans api-core : le message est l'horodatage Unix, un point et le corps, la clé est la chaîne du secret telle que stockée, et le résultat est v1= plus de l'hexadécimal en minuscules. Elido n'a pas d'événement link.clicked, n'attendez donc pas de livraisons au niveau du clic sur ce canal.

Fenêtres anti-rejeu et rotation du secret

Une signature valide prouve qui a envoyé une requête, pas quand. Quelqu'un qui capture une livraison signée, depuis une ligne de journal ou un proxy mal configuré, pourrait la renvoyer une semaine plus tard et le HMAC correspondrait toujours. La vérification de l'horodatage comble cet écart, et c'est votre travail : le worker de livraison d'Elido signe l'horodatage mais n'impose aucune fenêtre de votre côté. J'utilise 300 secondes. Gardez l'horloge du récepteur synchronisée par NTP, car un serveur qui dérive de quelques minutes se met à rejeter du trafic légitime.

Les nouvelles tentatives ne déclenchent pas la fenêtre. Chaque tentative reçoit un X-Webhook-Timestamp neuf et une nouvelle signature, tandis que X-Webhook-Delivery reste le même. Cette séparation vous donne les deux défenses : l'horodatage borne la durée pendant laquelle une requête capturée reste utilisable, et un index unique sur l'ID de livraison empêche une nouvelle tentative légitime d'être traitée deux fois. L'article sur les limites de débit et l'idempotence couvre le même schéma côté API entrante.

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

  • X-Elido-Signature, faite avec le nouveau secret
  • X-Elido-Signature-Previous, faite avec l'ancien

C'est pourquoi chaque extrait ci-dessus vérifie les deux en-têtes avec le seul secret qu'il détient. Un récepteur qui utilise encore l'ancien secret correspond au second en-tête ; une fois le nouveau déployé, il correspond au premier. Rien n'échoue entre-temps. 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 tôt dans la semaine.

Chronologie de rotation du secret de webhook : avant la rotation, seul X-Elido-Signature est envoyé ; après rotate-secret, pendant une période de grâce de sept jours, les livraisons portent X-Elido-Signature avec la nouvelle clé et X-Elido-Signature-Previous avec l'ancienne.

Pourquoi la vérification de signature de webhook échoue

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

  1. JSON resérialisé. JSON.stringify(req.body) ou json.dumps(payload) produit d'autres octets que ceux hachés par l'expéditeur : 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 plutôt que d'essayer de reconstruire la chaîne.
  2. Les mauvais octets de clé. Elido utilise la chaîne whsec_... entière comme clé HMAC. Retirer le préfixe, décoder le reste en hexadécimal ou en base64 (ce que font les bibliothèques Standard Webhooks) vous donne une autre clé. Un retour à la ligne final issu d'un echo vers un fichier de secrets produit le même effet, c'est pourquoi l'exemple Python appelle .strip().
  3. Encodage différent. Comparez v1= plus l'hexadécimal en minuscules à l'en-tête. Un condensé base64, une chaîne hexadécimale en majuscules ou un préfixe manquant ne correspondent jamais.
  4. Le mauvais horodatage. Utilisez la chaîne de l'en-tête X-Webhook-Timestamp, pas le champ timestamp du corps, et pas un nombre que vous avez analysé puis reformaté.

Deux points mineurs : comparer avec == fonctionne mais fuit du temps, utilisez donc la fonction en temps constant que fournit votre langage ; et un proxy qui décompresse ou réencode les corps cassera aussi la vérification, bien que ce soit rare pour de simples POST JSON.

Quand les deux côtés sont toujours en désaccord, journalisez l'horodatage, la longueur du corps et les premiers caractères hexadécimaux de votre condensé, puis exécutez la ligne openssl vue plus haut sur les mêmes entrées. Le côté qui correspond à openssl a raison. Pour savoir où la signature s'inscrit parmi les autres contrôles à vérifier chez n'importe quel fournisseur, voyez la liste de contrôle sécurité des raccourcisseurs d'URL.

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

À lire aussi sur le blog

Questions fréquentes

Comment vérifier la signature d'un webhook ?

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

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

Presque toujours parce que vous avez haché d'autres octets que l'expéditeur. Un analyseur de corps JSON s'est exécuté d'abord et vous avez resérialisé l'objet, ou 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 du secret exactement telle qu'émise, et journalisez les deux valeurs côte à côte.

Qu'est-ce que HMAC dans un webhook ?

HMAC est un hachage à clé : l'expéditeur mélange un secret partagé avec vous dans un hachage SHA-256 du message. Seul celui qui détient le secret peut produire une valeur correspondante ; une signature valide prouve donc que la requête vient de l'expéditeur et que le corps n'a pas été modifié en route.

Comment empêcher les attaques par rejeu de webhook ?

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

Faut-il utiliser l'hexadécimal ou le base64 pour une signature de webhook HMAC-SHA256 ?

Ce que l'expéditeur documente, car les deux encodages d'un même condensé ne sont jamais égaux. Elido envoie de l'hexadécimal en minuscules après un préfixe v1=. Shopify et la spécification Standard Webhooks utilisent le base64, et GitHub utilise l'hexadécimal après sha256=. Encodez votre condensé de la même façon avant de comparer.

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

Utilisez un expéditeur qui signe avec les deux clés pendant un temps. Après avoir renouvelé le secret d'un endpoint Elido, chaque livraison porte 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.

Sans inscription. Créez un compte gratuit pour 50 liens par mois.

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