Vérifier les signatures
N’importe qui connaissant l’URL de votre endpoint peut lui envoyer des requêtes. Skoup signe chaque
livraison avec le secret de signature de l’endpoint (whsec_…) : vous pouvez vérifier qu’une
requête vient vraiment de Skoup et que son corps n’a pas été modifié en chemin.
L’en-tête de signature
X-Skoup-Signature: t=1790150400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdtest le timestamp Unix (secondes) auquel Skoup a signé la livraison.v1vautHMAC-SHA256(secret, "{t}.{corps brut}"), encodé en hexadécimal.
Le timestamp fait partie de la chaîne signée : un attaquant ne peut pas rejouer une ancienne livraison avec un timestamp récent.
Pendant une rotation du secret, l’en-tête porte plusieurs valeurs v1,
une par secret valide. Une livraison est authentique si l’une d’elles correspond.
Étapes de vérification
- Lisez le corps brut de la requête, octet pour octet, avant tout parsing JSON.
- Découpez l’en-tête sur
,et récupéreztet chaque valeurv1. - Calculez
HMAC-SHA256(secret, t + "." + corpsBrut)et encodez-le en hexadécimal. - Comparez-le à chaque valeur
v1avec une comparaison à temps constant. - Rejetez la requête si
ts’écarte de plus de 5 minutes de votre horloge.
Cause n°1 des vérifications qui échouent : signer un corps re-sérialisé. Les frameworks qui parsent le JSON avant votre handler modifient espaces et ordre des clés. Signez toujours les octets bruts reçus.
Exemples de code
Node.js
import crypto from 'node:crypto'
import express from 'express'
const app = express()
const SECRET = process.env.SKOUP_WEBHOOK_SECRET // whsec_…
const TOLERANCE_SECONDS = 300
function verifySkoupSignature(rawBody, header, secret) {
const parts = header.split(',').map((part) => part.split('=', 2))
const timestamp = parts.find(([key]) => key === 't')?.[1]
const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value)
if (!timestamp || signatures.length === 0) return false
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest()
return signatures.some((signature) => {
const received = Buffer.from(signature, 'hex')
return received.length === expected.length && crypto.timingSafeEqual(received, expected)
})
}
// express.raw keeps the body as a Buffer: never use express.json() on this route.
app.post('/skoup', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-Skoup-Signature') ?? ''
if (!verifySkoupSignature(req.body.toString('utf8'), header, SECRET)) {
return res.status(400).send('Invalid signature')
}
const event = JSON.parse(req.body.toString('utf8'))
queue.push(event) // process asynchronously
res.sendStatus(200)
})Rotation du secret
Sur la page de l’endpoint, Régénérer le secret crée un nouveau secret. Pendant 24 heures,
l’ancien reste valide et chaque livraison porte deux signatures v1 — une par secret — : vous
déployez le nouveau secret sans perdre un seul event. Au-delà de 24 heures, seul le nouveau signe.
Si un secret a fuité, choisissez Régénérer immédiatement : l’ancien cesse aussitôt de signer.
Déboguer une vérification qui échoue
Chaque livraison, dans Réglages → Développeurs → Webhooks, montre les en-têtes et le corps exacts qui ont été envoyés. Recalculez la signature de ce corps avec votre secret et comparez : si elle correspond, votre code lit un autre corps que celui envoyé par Skoup — en général un corps parsé puis re-sérialisé.