Passer au contenu principal

Vérifier les signatures

Comment authentifier les webhooks DZBuild entrants. Comparaison HMAC à temps constant + check de fraîcheur, avec code copy-paste en Node.js, PHP, Python et Go.

Écrit par Support

🛑 Important — Les signatures des webhooks API v1 ne sont pas encore vérifiables par les marchands

Les livraisons issues de POST /v1/webhooks portent un en-tête X-DZ-Signature, mais il n'est pas calculé à partir du secret propre au webhook que vous avez reçu à l'enregistrement — ce secret ne joue aucun rôle dans la signature aujourd'hui.

Tout contrôle HMAC que vous écrirez contre votre secret de webhook rejettera donc 100 % des livraisons API v1 authentiques. Traitez X-DZ-Signature comme une valeur opaque en attendant la signature par webhook.

Que faire à la place pour les livraisons API v1 :

  1. Rendez l'URL indevinable — un long segment de chemin aléatoire, ou un token partagé en query string que vous contrôlez à l'arrivée.

  2. N'acceptez que du POST en HTTPS, et vérifiez que X-DZ-Timestamp est dans les 5 minutes de votre horloge.

  3. Relisez l'enregistrement avant d'agir. Appelez GET /v1/orders/{id} avec votre clé API et faites confiance à ça, pas au body poussé.

  4. Dédupliquez sur le delivery_id du body.

Le code du reste de cette page s'applique à l'addon Webhooks marchand (/dashboard/webhooks, plan Unlimited et au-delà), dont les signatures sont vérifiables avec votre secret propre à chaque endpoint.

La recette (addon Webhooks marchand)

L'addon envoie un en-tête à la Stripe, délimité par des virgules, et signe le body brut — pas un hash de celui-ci :

X-DZ-Signature: t=<unix seconds>,v1=<hex hmac-sha256>expected = hex( hmac_sha256( WEBHOOK_SECRET, t + "." + raw_body ) )if (!constant_time_equal(expected, v1)) reject 401
if (abs(now - t) > 300)                 reject 401   # fenêtre rejeu ±5 min

Trois règles pour rester safe :

  1. Utilisez les bytes raw du body. Re-sérialiser le JSON change l'entrée de la signature.

  2. Comparez à temps constant. Un == classique fuit du timing exploitable en bruteforce.

  3. Rejetez les timestamps périmés (plus de 5 min d'écart avec l'horloge serveur). Lancez NTP.

L'ensemble complet des en-têtes d'une livraison de l'addon :

Content-Type:    application/json
User-Agent:      DZBuild-Webhooks/1.0
X-DZ-Timestamp:  <unix seconds>
X-DZ-Signature:  t=<unix seconds>,v1=<hex hmac-sha256>
X-DZ-Event:      order.confirmed
X-DZ-Delivery:   <numeric delivery id>
X-DZ-Token:      <your endpoint secret, in plain text>

X-DZ-Token est le jumeau pratique de la signature, destiné aux outils no-code (n8n, Make, Zapier) qui ne savent faire que de l'auth par en-tête : comparez-le à votre secret stocké avec un contrôle à temps constant. C'est un credential de type bearer dans un en-tête — ne l'utilisez jamais qu'en HTTPS, et préférez le HMAC quand vous écrivez du vrai code.

Code

Node.js (Express)

import crypto from 'node:crypto';
import express from 'express';const app = express();
const WEBHOOK_SECRET = process.env.DZBUILD_WEBHOOK_SECRET;// "t=1717112657,v1=abc..." → { t: "1717112657", v1: "abc..." }
function parseSigHeader(raw) {
  const out = {};
  for (const part of String(raw || '').split(',')) {
    const i = part.indexOf('=');
    if (i > 0) out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
  }
  return out;
}// IMPORTANT : capturez le body brut pour le HMAC, séparément du JSON parsé.
app.post('/webhooks/dzbuild',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const { t: ts, v1: sig } = parseSigHeader(req.get('X-DZ-Signature'));
    if (!ts || !sig) return res.status(401).end();    if (Math.abs(Math.floor(Date.now()/1000) - Number(ts)) > 300) {
      return res.status(401).end();   // timestamp périmé ou futur
    }    const expected = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(`${ts}.${req.body.toString('utf8')}`)   // body brut, pas un hash
      .digest('hex');    // Contrôle de longueur d'abord — timingSafeEqual lève si les buffers diffèrent.
    if (expected.length !== sig.length ||
        !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
      return res.status(401).end();
    }    // Vérifié — vous pouvez maintenant parser et agir.
    const event = JSON.parse(req.body.toString('utf8'));
    console.log('verified', req.get('X-DZ-Event'), event);
    res.status(200).end();   // ack au plus vite
  });app.listen(3000);

PHP (raw)

<?php
$secret = getenv('DZBUILD_WEBHOOK_SECRET');
$body   = file_get_contents('php://input');     // body brut
$header = $_SERVER['HTTP_X_DZ_SIGNATURE'] ?? '';$parts = [];
foreach (explode(',', $header) as $piece) {
    $kv = explode('=', trim($piece), 2);
    if (count($kv) === 2) { $parts[$kv[0]] = $kv[1]; }
}
$ts  = $parts['t']  ?? '';
$sig = $parts['v1'] ?? '';if ($ts === '' || $sig === '') { http_response_code(401); exit; }
if (abs(time() - (int)$ts) > 300) { http_response_code(401); exit; }$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);if (!hash_equals($expected, strtolower($sig))) {
    http_response_code(401);
    exit;
}$event = json_decode($body, true);
// Traitez $event['event'], $event['data']
http_response_code(200);

Sous Laravel, utilisez un middleware de route ou un contrôleur qui lit `$request->getContent()` pour le body brut. Désactivez le CSRF sur la route webhook.

Python (Flask)

import hashlib, hmac, os, time
from flask import Flask, request, abortapp = Flask(__name__)
WEBHOOK_SECRET = os.environ['DZBUILD_WEBHOOK_SECRET'].encode()def parse_sig(raw):
    out = {}
    for part in (raw or '').split(','):
        k, _, v = part.partition('=')
        if v:
            out[k.strip()] = v.strip()
    return [email protected]('/webhooks/dzbuild')
def receive():
    parts = parse_sig(request.headers.get('X-DZ-Signature'))
    ts, sig = parts.get('t'), parts.get('v1')
    if not ts or not sig: abort(401)
    if abs(int(time.time()) - int(ts)) > 300: abort(401)    body = request.get_data()      # bytes bruts — N'UTILISEZ PAS request.json
    expected = hmac.new(WEBHOOK_SECRET,
                        ts.encode() + b'.' + body,
                        hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig.lower()): abort(401)    event = request.get_json()
    print('verified', request.headers.get('X-DZ-Event'), event)
    return '', 200

Go (net/http)

package mainimport (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)var secret = []byte(os.Getenv("DZBUILD_WEBHOOK_SECRET"))func parseSig(h string) (ts, v1 string) {
    for _, part := range strings.Split(h, ",") {
        kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
        if len(kv) != 2 { continue }
        switch kv[0] {
        case "t":
            ts = kv[1]
        case "v1":
            v1 = kv[1]
        }
    }
    return
}func receive(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil { http.Error(w, "read", 400); return }    ts, sig := parseSig(r.Header.Get("X-DZ-Signature"))
    if ts == "" || sig == "" { http.Error(w, "no sig", 401); return }    tsInt, err := strconv.ParseInt(ts, 10, 64)
    if err != nil { http.Error(w, "ts", 401); return }
    if abs(time.Now().Unix() - tsInt) > 300 { http.Error(w, "stale", 401); return }    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(ts + "." + string(body)))
    expected := hex.EncodeToString(mac.Sum(nil))    if !hmac.Equal([]byte(expected), []byte(sig)) {
        http.Error(w, "bad sig", 401); return
    }
    w.WriteHeader(http.StatusOK)
}func abs(x int64) int64 { if x < 0 { return -x }; return x }

Contraintes côté récepteur

Elles s'appliquent aux livraisons API v1 et sont la réponse habituelle à « mon endpoint n'est jamais appelé » :

Contrainte

Valeur

Ce qui arrive si vous l'enfreignez

Timeout de connexion

5 secondes

Compté comme un échec de transport — une tentative, puis abandon

Timeout total

10 secondes

Idem : abandonné, jamais re-tenté

Redirections

Non suivies

Un 301/302 est un échec, et un 3xx n'est jamais re-tenté

Vérification TLS

Stricte

Les certificats auto-signés ou expirés échouent sans retry

Méthode / body

POST simple, body JSON

Pointez le webhook sur l'URL finale (pas de redirection www → apex, pas de rebond HTTP → HTTPS) et servez un certificat reconnu publiquement.

Erreurs classiques

Erreur

Symptôme

Correctif

Vérifier une livraison API v1 avec votre secret de webhook

Chaque livraison rejetée en « mauvaise signature »

C'est attendu — l'API v1 ne signe pas avec ce secret. Voir l'encadré en haut de page

Re-sérialiser le body JSON avant de signer

La signature ne matche jamais

Utilisez les bytes raw du body — voir les notes par framework dans Enregistrement

Hacher le body avant le HMAC

La signature ne matche jamais

L'addon signe t + "." + raw_body, pas un digest du body

Lire t depuis X-DZ-Timestamp mais v1 depuis un en-tête nu

Erreurs de parsing / signature vide

X-DZ-Signature est délimité par des virgules : t=…,v1=…

Dérive d'horloge serveur

Rejets « Timestamp out of window »

Lancez NTP, vérifiez timedatectl status sous Linux

Comparer avec == au lieu d'un temps constant

Vulnérabilité subtile aux timing-attacks

Utilisez crypto.timingSafeEqual / hmac.compare_digest / hash_equals

timingSafeEqual sans contrôle de longueur

RangeError levée au lieu d'un 401

Comparez d'abord les longueurs, comme dans l'exemple Node

Logger le secret sur disque

Le secret finit dans vos fichiers de logs

Ne le loggez pas ; utilisez un secrets store ; régénérez-le en cas de fuite

Répondre 200 immédiatement et traiter plus tard

Événements perdus quand votre worker crashe

Persistez d'abord dans votre propre file puis acquittez, OU faites le travail de façon synchrone et acquittez en dernier

Idempotence de votre côté

Le même delivery_id peut arriver plusieurs fois. En API v1, cela prend une forme bien précise :

  • Un endpoint qui répond 5xx est re-POSTé toutes les 60 secondes, indéfiniment. Les doublons issus de ce chemin sont la routine, pas l'exception — la déduplication est obligatoire, pas défensive.

  • Un endpoint qui time out n'obtient aucun retry. La livraison est abandonnée après une tentative : un endpoint lent perd des événements plutôt que d'en recevoir en double. Acquittez vite et faites le travail en asynchrone.

Dédupliquez côté réception :

INSERT INTO webhook_log (delivery_id, event, body) VALUES (?, ?, ?);
-- Attrapez la violation UNIQUE sur delivery_id → déjà traité, répondez 200 quand même

Ce pattern fait que même si notre retry vous re-sollicite, vous faites le travail une seule fois et acquittez vite.

Avez-vous trouvé la réponse à votre question ?