Webhooks QR Signés : Vérifier Chaque Scan avec HMAC
Il y a deux ans, un client a découvert que n'importe qui pouvait forger des événements vers son endpoint webhook et déclencher des remboursements automatique...
Webhooks QR Signés : Vérifier Chaque Scan avec HMAC
Il y a deux ans, un client a découvert que n'importe qui pouvait forger des événements scan.created vers son endpoint webhook et déclencher des remboursements automatiques sur sa boutique. Le code vérifiait la présence d'une signature dans l'en-tête, mais jamais sa validité. La faille est restée trois semaines en production avant qu'on la trouve.
Ce genre d'incident arrive parce que la plupart des équipes traitent la vérification HMAC comme une case à cocher plutôt que comme une implémentation cryptographique à faire correctement. Chez QR Code Agency, chaque webhook envoyé (scan.created, dynamic_qr.created, dynamic_qr.updated, les seuils de quota) porte une signature HMAC-SHA256 au format t=<unix>,v1=<hex>. Le format ressemble à celui de Stripe, mais le fait de recevoir une signature ne protège rien si le code côté serveur ne la vérifie pas correctement. Ce guide couvre les trois endroits où les développeurs cassent la sécurité webhook sans s'en rendre compte : le calcul de la signature, la comparaison, et la fenêtre de rejeu.

Calculer la signature HMAC sans se tromper
La première erreur classique consiste à signer le mauvais objet. Beaucoup de développeurs re-sérialisent le JSON reçu avant de calculer le HMAC, en espérant que JSON.stringify(req.body) produira la même chaîne que celle signée côté émetteur. Ça ne fonctionne presque jamais : l'ordre des clés, les espaces, l'encodage Unicode changent la chaîne d'octets, donc le hash.
La règle absolue : tu signes le corps brut de la requête, pas sa version parsée. Ça veut dire capturer le raw body avant que ton framework le transforme en objet.
// Express - il faut le raw body, pas req.body
app.post('/webhooks/qr-scans',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-qrstudio-signature'];
const rawBody = req.body; // Buffer, pas objet
// ...
}
);
Une fois le raw body en main, tu extrais l'horodatage et la signature de l'en-tête t=<unix>,v1=<hex>, tu reconstruis la chaîne signée (timestamp.rawBody), et tu calcules ton propre HMAC avec le secret partagé.
const crypto = require('crypto');
function computeSignature(timestamp, rawBody, secret) {
const signedPayload = `${timestamp}.${rawBody}`;
return crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
}
Si tu génères le HMAC sur rawBody seul, sans l'horodatage inclus dans la chaîne signée, tu perds la protection anti-rejeu de base : un attaquant qui intercepte un webhook légitime pourra le rejouer indéfiniment avec la même signature valide. L'horodatage doit faire partie du message signé, pas juste voyager à côté dans un en-tête séparé qu'on vérifie mollement.
Sécurité webhook et comparaison à temps constant
Voilà l'erreur que j'ai commise sur un projet interne, avant même de travailler chez QR Code Agency. Le code de vérification ressemblait à ça :
// NE FAIS JAMAIS ÇA
if (computedSignature === receivedSignature) {
// traiter le webhook
}
Cette ligne semble correcte. Elle ne l'est pas. L'opérateur === sur des chaînes compare caractère par caractère et s'arrête au premier mismatch. Le temps de réponse de ta fonction varie donc légèrement selon combien de caractères corrects précèdent l'erreur. Un attaquant patient, avec suffisamment de requêtes et une mesure fine de la latence réseau, peut reconstruire la signature octet par octet en observant ces micro-différences de temps. C'est ce qu'on appelle une timing attack, documentée depuis des années dans la littérature crypto, et qui reste exploitable dès que la comparaison n'est pas à temps constant.
La correction est triviale une fois qu'on connaît le problème : utiliser une fonction de comparaison qui prend toujours le même temps, peu importe où se trouve la différence.
const crypto = require('crypto');
function verifySignature(computed, received) {
const bufA = Buffer.from(computed, 'hex');
const bufB = Buffer.from(received, 'hex');
if (bufA.length !== bufB.length) {
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
En Python, l'équivalent existe dans la bibliothèque standard : hmac.compare_digest(a, b) fait exactement ce travail et devrait être ton premier réflexe plutôt que a == b.
import hmac
import hashlib
def verify_signature(payload: bytes, timestamp: str, secret: str, received_sig: str) -> bool:
signed_payload = f"{timestamp}.".encode() + payload
computed = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(computed, received_sig)
Le point que je répète à chaque développeur qui intègre notre API QR pour développeurs : la présence d'un HMAC ne sécurise rien si la comparaison finale se fait avec un opérateur d'égalité classique. C'est le genre de détail qui ne casse jamais en développement local et qui devient un vrai risque en production, sous charge, avec un attaquant qui a le temps.

Bloquer les rejeux avec la fenêtre d'horodatage
Une signature valide ne garantit pas que le webhook est récent. Si un attaquant intercepte une requête légitime, même sans en connaître le contenu en clair, il peut la rejouer telle quelle vers ton endpoint. La signature reste valide puisque le corps et l'horodatage n'ont pas changé. Sans vérification de fraîcheur, ton système traite le même événement scan.created autant de fois que l'attaquant le souhaite.
La fenêtre de rejeu qu'on applique côté QR Code Agency est de 5 minutes, calquée sur ce que fait Stripe. Ton code doit rejeter tout webhook dont l'horodatage sort de cette plage, dans un sens comme dans l'autre.
function isTimestampValid(timestamp, toleranceSeconds = 300) {
const now = Math.floor(Date.now() / 1000);
const eventTime = parseInt(timestamp, 10);
const diff = Math.abs(now - eventTime);
return diff <= toleranceSeconds;
}
app.post('/webhooks/qr-scans', express.raw({ type: 'application/json' }), (req, res) => {
const sigHeader = req.headers['x-qrstudio-signature'];
const [tsPart, sigPart] = sigHeader.split(',');
const timestamp = tsPart.split('=')[1];
const receivedSig = sigPart.split('=')[1];
if (!isTimestampValid(timestamp)) {
return res.status(400).send('Timestamp hors fenêtre');
}
const computed = computeSignature(timestamp, req.body, process.env.WEBHOOK_SECRET);
if (!verifySignature(computed, receivedSig)) {
return res.status(401).send('Signature invalide');
}
res.status(200).send('OK');
});
Deux détails qui changent tout ici. D'abord, l'ordre des vérifications : valider le timestamp avant de calculer la signature évite de gaspiller du CPU sur un événement déjà obsolète. Ensuite, l'idempotence : même avec une fenêtre de 5 minutes, un événement peut arriver deux fois à cause d'un retry légitime. Notre politique de retry envoie jusqu'à 5 tentatives avec un backoff exponentiel (immédiat, 1 minute, 5 minutes, 30 minutes, 2 heures). Ton système doit donc aussi dédupliquer par ID d'événement, pas seulement filtrer par horodatage.
Si tu construis une intégration qui déclenche des actions sensibles (mise à jour de stock, notification client, décrémentation de quota), stocke les IDs d'événements traités dans les dernières 24 heures et rejette les doublons, même signés correctement.
Faire tourner les secrets sans casser la prod
Le secret partagé qui sert au HMAC finit toujours par devoir changer : rotation planifiée, fuite suspectée, ou simple bonne pratique annuelle. Le problème classique, c'est que la rotation brutale d'un seul secret casse la vérification de tous les webhooks en transit au moment du switch, surtout si l'émetteur et le récepteur ne changent pas exactement en même temps.
La solution qu'on recommande, et qu'on applique nous-mêmes côté API : accepter deux secrets valides en parallèle pendant une fenêtre de transition.
const activeSecrets = [
process.env.WEBHOOK_SECRET_CURRENT,
process.env.WEBHOOK_SECRET_PREVIOUS, // vide une fois la rotation terminée
].filter(Boolean);
function verifyWithRotation(timestamp, rawBody, receivedSig, secrets) {
return secrets.some((secret) => {
const computed = computeSignature(timestamp, rawBody, secret);
return verifySignature(computed, receivedSig);
});
}
Le workflow concret : tu génères un nouveau secret dans ton tableau de bord, tu le déploies comme WEBHOOK_SECRET_CURRENT tout en gardant l'ancien comme WEBHOOK_SECRET_PREVIOUS, tu laisses tourner 48 à 72 heures pour couvrir tous les retries en cours, puis tu supprimes l'ancien secret. Aucune interruption de réception côté client, aucun webhook perdu pendant la transition.
Le versioning de signature suit la même logique. Le préfixe v1= dans notre en-tête existe précisément pour ça : le jour où on introduit un v2= avec un algorithme différent, les intégrations existantes continuent de fonctionner sur v1 pendant que les nouvelles migrent progressivement. Ne code jamais en dur l'hypothèse qu'il n'existera qu'un seul format de signature dans l'en-tête.

Les erreurs que j'ai vraiment faites
Sur le projet où j'ai découvert la faille de comparaison, j'avais aussi commis une deuxième erreur : logger la signature reçue et le secret en clair pour débugger un problème de format. Ce log a traîné trois mois dans un système de logging tiers avant que quelqu'un le remarque en audit. Chez QR Code Agency, on ne log jamais les secrets bruts, les clés API en clair, ni le corps complet des payloads de webhook, exactement pour éviter ce genre de fuite silencieuse qui traîne dans des systèmes qu'on ne contrôle pas entièrement.
Troisième erreur, plus bête : j'avais mis la validation du timestamp après la vérification de signature. Résultat, un attaquant pouvait envoyer des milliers de payloads avec des signatures aléatoires pour faire tourner le CPU du serveur en boucle sur le calcul HMAC, avant même d'atteindre le check de fraîcheur qui les aurait rejetés immédiatement. Inverser l'ordre des deux vérifications a réduit la charge CPU de cet endpoint de façon mesurable sous test de charge.
Si tu construis une intégration API QR code développeur qui reçoit des volumes significatifs, ces détails d'ordonnancement comptent. Un endpoint mal ordonné devient une surface d'attaque par déni de service low-cost avant même d'être une faille d'authentification.
![Line graph showing scan analytics dashboard with country breakdown]
Pour ceux qui débutent avec notre API et cherchent à comprendre l'ensemble des mécanismes de sécurité en place, j'ai détaillé le fonctionnement complet de la protection SSRF et du chiffrement des clés dans cet article sur la sécurité des codes QR. Et si tu compares plusieurs fournisseurs avant de choisir ton stack, le comparatif entre Bitly et 6 alternatives QR détaille aussi qui propose des webhooks signés natifs et qui les facture en add-on.
Ce qu'il faut retenir en production
Un webhook signé mal vérifié donne l'illusion de la sécurité sans en offrir la substance. La checklist minimale avant de mettre en prod un endpoint qui reçoit des événements de scan : capturer le raw body avant tout parsing JSON, comparer les signatures avec timingSafeEqual ou compare_digest, valider l'horodatage avant de calculer quoi que ce soit, et prévoir dès le départ un mécanisme de double secret pour la rotation.
Chaque webhook signé qu'on envoie chez QR Code Agency suit ce format t=<unix>,v1=<hex> avec fenêtre de rejeu de 5 minutes, retry exponentiel sur 5 tentatives, et log de livraison conservé 30 jours pour que tu puisses auditer ce qui a été envoyé et reçu de ton côté.
Action concrète pour cette semaine : ouvre ton code de vérification de webhook actuel, cherche la ligne où tu compares deux signatures, et vérifie si elle utilise === ou ==. Si oui, remplace-la par crypto.timingSafeEqual ou hmac.compare_digest avant ta prochaine mise en production.
A lire aussi
Keep reading
UTM sur QR codes : structurer vos campagnes dans GA4
Un paramètre UTM mal tagué sur un QR code imprimé se paie jusqu'à la fin du tirage. Voici une nomenclature fiable pour structurer vos campagnes print et garder des données GA4 propres.
Taille minimale d'un QR code imprimé : la règle des 10 % expliquée
La règle des 10 % relie la taille d'un QR code à sa distance de scan : un code lu à 1 mètre doit mesurer au moins 10 cm de côté. Voici comment appliquer ce calcul selon le support, les facteurs qui forcent à voir plus grand, et les erreurs qui coûtent un tirage complet.
QR code statique ou dynamique : lequel choisir selon votre usage
Un QR statique fige sa destination pour toujours ; un dynamique la laisse modifiable après impression, avec des statistiques de scan en prime. Voici la différence concrète, les cas où chaque format gagne, et la question à se poser avant d'imprimer quoi que ce soit.