📄
Webhooks·Débutant·10 min

Vérifier la signature HMAC d'un webhook

Apprenez à valider les signatures HMAC-SHA256 que Coffrify ajoute à chaque livraison webhook, en gérant les deux formats d'en-tête (legacy et Standard Webhooks) et la protection contre les attaques par rejeu.

Télécharger en PDF

À chaque livraison, Coffrify signe le corps de la requête avec votre secret webhook (préfixé whsec_) à l'aide de HMAC-SHA256. Votre endpoint doit vérifier cette signature avant de traiter l'événement : cela garantit que la requête provient bien de Coffrify et n'a pas été altérée en transit. Coffrify émet deux formats d'en-tête en parallèle : le format legacy (X-Coffrify-Signature) pour la compatibilité ascendante, et le format Standard Webhooks (webhook-signature) pour l'interopérabilité avec des outils comme Svix, Hookdeck ou NestJS. La signature expire après 300 secondes (5 minutes) pour protéger contre les attaques par rejeu.

En-têtes envoyés par Coffrify

En-têteFormatDescription
X-Coffrify-Signaturet=<ts>,v1=<hex64>Format legacy. ts = horodatage Unix (secondes), hex64 = HMAC-SHA256 sur ${ts}.${body} encodé en hexadécimal.
webhook-id<uuid>Identifiant stable de la livraison, identique pour chaque tentative (utile pour la déduplication).
webhook-timestamp<ts>Horodatage Unix en secondes (Standard Webhooks).
webhook-signaturev1,<base64>Format Standard Webhooks. Signé sur ${webhook-id}.${ts}.${body}, encodé en base64. Plusieurs signatures séparées par des espaces si rotation en cours.
X-Coffrify-Event-Id<uuid>Identique à webhook-id. Conservé pour compatibilité.
X-Coffrify-Event-Type<string>Type d'événement, ex. transfer.downloaded.

Format du secret

Le secret est généré automatiquement à la création du webhook et retourné une seule fois dans la réponse POST /v1/webhooks. Il a la forme whsec_ suivi de 64 caractères hexadécimaux représentant 32 octets aléatoires (256 bits d'entropie). Lorsque le préfixe whsec_ est présent, le moteur de signature Coffrify décode la partie hexadécimale comme les octets bruts de la clé HMAC, conformément à la spécification Standard Webhooks. Pour la rotation, utilisez PATCH /v1/webhooks/:id : pendant la transition, les deux secrets sont acceptés et matched_secret_index vous indique lequel a matché.

Algorithme de vérification (format legacy)

Pour le format X-Coffrify-Signature: t=<ts>,v1=<hex>, voici les étapes exactes implémentées dans lib/api/webhook-signature.ts et supabase/functions/_shared/coffrify-webhook-signing.ts : 1. Extraire ts et v1 depuis l'en-tête. 2. Vérifier que |now() - ts| <= 300 secondes (tolérance anti-rejeu). 3. Construire le message canonique : ts + "." + body (corps brut, jamais parsé). 4. Calculer HMAC-SHA256(secret_bytes, message) et encoder en hexadécimal. 5. Comparer avec timingSafeEqual pour éviter les attaques temporelles. Pour le format Standard Webhooks (webhook-signature), le message canonique est webhook-id + "." + webhook-timestamp + "." + body, et la signature est encodée en base64 (non hexadécimal).

Implémentation dans votre endpoint

import crypto from 'node:crypto';
import { createServer } from 'node:http';
 
const WEBHOOK_SECRET = process.env.COFFRIFY_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;
 
/**
* Vérifie la signature HMAC-SHA256 au format legacy Coffrify.
* En-tête : X-Coffrify-Signature: t=<ts>,v1=<hex>
*/
function verifyCoffrifySignature(rawBody, signatureHeader, secret) {
if (!signatureHeader) return { valid: false, reason: 'missing_signature' };
 
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('='))
);
const ts = parseInt(parts['t'], 10);
const provided = parts['v1'];
 
if (!ts || !provided) return { valid: false, reason: 'malformed' };
if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) {
return { valid: false, reason: 'expired' };
}
 
// Les octets bruts de la clé : décoder whsec_<hex>
const hexKey = secret.startsWith('whsec_') ? secret.slice(6) : null;
const keyBuf = hexKey
? Buffer.from(hexKey, 'hex')
: Buffer.from(secret);
 
const expected = crypto
.createHmac('sha256', keyBuf)
.update(`${ts}.${rawBody}`)
.digest('hex');
 
if (provided.length !== expected.length) {
return { valid: false, reason: 'length_mismatch' };
}
 
const valid = crypto.timingSafeEqual(
Buffer.from(provided, 'hex'),
Buffer.from(expected, 'hex')
);
return valid ? { valid: true } : { valid: false, reason: 'mismatch' };
}
 
createServer((req, res) => {
if (req.method !== 'POST') {
res.writeHead(405).end();
return;
}
 
let rawBody = '';
req.on('data', (chunk) => { rawBody += chunk; });
req.on('end', () => {
const sig = req.headers['x-coffrify-signature'];
const result = verifyCoffrifySignature(rawBody, sig, WEBHOOK_SECRET);
 
if (!result.valid) {
console.warn('Signature invalide :', result.reason);
res.writeHead(401, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ error: result.reason }));
return;
}
 
const event = JSON.parse(rawBody);
console.log('Événement reçu :', event.type, event.id);
// Traitez l'événement ici...
res.writeHead(200).end('ok');
});
}).listen(3000);

Structure du payload reçu

Le corps de la requête POST est un objet JSON dont la structure est identique pour tous les types d'événements. C'est ce corps brut (avant tout parsing) qui doit être utilisé pour calculer la signature.

{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "transfer.downloaded",
"created_at": "2026-06-06T10:23:00.000Z",
"workspace_id": "ws_9f8e7d6c-5b4a-3c2d-1e0f-a1b2c3d4e5f6",
"data": {
"transfer_id": "tr_aabbccdd-1122-3344-5566-778899aabbcc",
"title": "Rapport Q2 2026",
"recipient_email": "destinataire@example.com",
"files_count": 3,
"encryption_mode": "server",
"status": "downloaded"
}
}

Vérification avec le format Standard Webhooks

Si votre infrastructure utilise un récepteur compatible Standard Webhooks (Svix Receiver, module NestJS, Hookdeck...), utilisez les en-têtes webhook-id, webhook-timestamp et webhook-signature à la place du format legacy. Le message canonique est alors webhook-id + "." + webhook-timestamp + "." + body, et la signature v1,<base64>. La tolérance est identique (300 secondes). Lors d'une rotation de secret, plusieurs signatures séparées par des espaces peuvent apparaître dans webhook-signature : votre récepteur doit en valider au moins une.

// Vérification Standard Webhooks (Node)
import crypto from 'node:crypto';
 
function verifyStandardWebhooks(rawBody, headers, secret) {
const webhookId = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const sigHeader = headers['webhook-signature'];
 
if (!webhookId || !timestamp || !sigHeader) {
return { valid: false, reason: 'missing_id_or_timestamp_for_standard' };
}
 
const ts = parseInt(timestamp, 10);
if (Math.abs(Date.now() / 1000 - ts) > 300) {
return { valid: false, reason: 'expired' };
}
 
const hexKey = secret.startsWith('whsec_') ? secret.slice(6) : null;
const keyBuf = hexKey ? Buffer.from(hexKey, 'hex') : Buffer.from(secret);
 
const message = `${webhookId}.${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', keyBuf)
.update(message)
.digest('base64');
 
// Plusieurs signatures possibles séparées par un espace (rotation)
const candidates = sigHeader.split(' ')
.filter((s) => s.startsWith('v1,'))
.map((s) => s.slice(3));
 
const valid = candidates.some((c) => {
try {
return crypto.timingSafeEqual(
Buffer.from(c, 'base64'),
Buffer.from(expected, 'base64')
);
} catch { return false; }
});
 
return valid ? { valid: true } : { valid: false, reason: 'mismatch' };
}

Codes d'erreur et résolution

CodeQuandRésolution
missing_signatureL'en-tête X-Coffrify-Signature (ou webhook-signature) est absent.Vérifiez que votre proxy ou load balancer ne supprime pas les en-têtes personnalisés. Testez directement avec curl.
malformedL'en-tête ne contient pas les parties t= et v1=.Contrôlez que vous lisez bien X-Coffrify-Signature et non un autre en-tête.
expiredL'écart entre l'horodatage du header et l'heure serveur dépasse 300 secondes.Synchronisez l'horloge de votre serveur (NTP). Si vous rejouez des requêtes en test, désactivez temporairement la vérification du timestamp.
length_mismatchLa longueur de la signature fournie diffère de la signature calculée.Assurez-vous de lire le body en bytes bruts (pas parsé, pas retranscrit). Buffer.from sans encoding explicite ou utf8 uniquement.
invalid_hexLa partie v1= contient des caractères non hexadécimaux.Vérifiez que l'en-tête n'est pas tronqué ou corrompu par votre framework (ex. body-parser qui transforme les buffers).
mismatchLa signature est bien formée mais ne correspond pas.Vérifiez : (1) vous utilisez le body brut avant tout parsing JSON, (2) le secret whsec_ est exact (aucun espace ou saut de ligne), (3) le message canonique est ts.body et non body seul.
no_secretLe secret est vide ou absent côté vérificateur.Chargez COFFRIFY_WEBHOOK_SECRET depuis votre gestionnaire de secrets (Vercel Env, AWS Secrets Manager...).

Rotation du secret sans interruption

Pour effectuer une rotation sans rejeter de livraisons en cours : utilisez PATCH /v1/webhooks/:id pour mettre à jour le secret. Pendant la période de transition, votre endpoint peut tenter la vérification avec le nouveau secret, puis avec l'ancien en cas d'échec. La réponse de verifySignature retourne matched_secret_index (0 = secret actuel, 1 = ancien) pour vous permettre de détecter les livraisons qui ont utilisé l'ancien secret et de planifier l'abandon de celui-ci.

// Rotation de secret : accepter les deux pendant la transition
const NEW_SECRET = process.env.COFFRIFY_WEBHOOK_SECRET;
const OLD_SECRET = process.env.COFFRIFY_WEBHOOK_SECRET_OLD; // garde l'ancien
 
function verifyWithRotation(rawBody, signatureHeader) {
for (const [index, secret] of [NEW_SECRET, OLD_SECRET].entries()) {
if (!secret) continue;
const result = verifyCoffrifySignature(rawBody, signatureHeader, secret);
if (result.valid) {
if (index === 1) console.warn('Livraison signée avec l ancien secret - planifiez la migration');
return result;
}
}
return { valid: false, reason: 'mismatch' };
}

Voir aussi

Continuer

Autres tutoriels à suivre