Verifica della Sicurezza dei Webhook (HMAC Signature)
Per garantire che ogni webhook ricevuto provenga realmente dal nostro sistema e non sia stato alterato o falsificato, utilizziamo il meccanismo standard di firma HMAC-SHA256, lo stesso adottato da Stripe, Shopify, GitHub, PayPal e molti altri servizi affidabili.
Principi di sicurezza garantiti
- Autenticità — solo chi possiede la chiave segreta può generare una firma valida
- Integrità — il contenuto del payload non è stato modificato durante il transito
- Protezione da replay — grazie al timestamp, le richieste vecchie vengono rifiutate
Componenti coinvolti
| Header HTTP | Descrizione | Obbligatorio | Esempio valore |
|---|---|---|---|
Webhook-Timestamp | Timestamp Unix (secondi) di generazione del webhook | Sì | 1733678400 |
Webhook-Signature | Firma nel formato t=<timestamp>,v1=<hmac_sha256_hex> | Sì | t=1733678400,v1=4f8a9b2c...d7e1f3 |
Come calcoliamo la firma (lato nostro)
-
Concateniamo: timestamp + ”.” + raw_body (il
raw_bodyè il JSON esattamente come inviato, senza parsing o modifiche di formattazione) -
Calcoliamo l’HMAC-SHA256 utilizzando la chiave segreta condivisa con voi
-
Convertiamo il risultato in stringa esadecimale minuscola
-
Costruiamo l’header:
Webhook-Signature: t=<timestamp>,v1=<firma_calcolata>Come verificare la firma (lato vostro – passi obbligatori)
-
Controllare la presenza degli header
SeWebhook-TimestampoWebhook-Signaturemancano → rispondere con 401 Unauthorized -
Verificare la freschezza del timestamp (anti-replay)
|current_time - timestamp| ≤ 300 secondi (tolleranza massima 5 minuti)Se il timestamp è troppo vecchio → 401
-
Estrarre la firma v1 Il valore di Webhook-Signature può contenere più firme separate da virgola (per supportare rotazione chiavi in futuro). Prendete solo la parte che inizia con v1=
Esempio:
t=1733678400,v1=abcdef123456,v0=oldone → usare solo v1=abcdef123456- Ricalcolare la firma attesa
payload_to_sign = timestamp + "." + request.getContent() // RAW body!
expected = HMAC-SHA256(payload_to_sign, YOUR_WEBHOOK_SECRET).hexdigest()-
Confrontare in modo sicuro Utilizzare una funzione di comparazione timing-safe (es.
hash_equals()in PHP,crypto.timingSafeEqual()in Node.js)Se non corrispondono esattamente → 401 Unauthorized
Esempio implementazione PHP
<?php
function verifyWebhookSignature($request, $webhookSecret) {
// 1. Ottenere gli header
$timestamp = $request->header('Webhook-Timestamp');
$signature = $request->header('Webhook-Signature');
if (!$timestamp || !$signature) {
return false; // 401 Unauthorized
}
// 2. Verificare freschezza del timestamp (max 5 minuti)
$currentTime = time();
if (abs($currentTime - $timestamp) > 300) {
return false; // Timestamp troppo vecchio
}
// 3. Estrarre la firma v1
preg_match('/v1=([a-f0-9]+)/', $signature, $matches);
if (!isset($matches[1])) {
return false;
}
$receivedSignature = $matches[1];
// 4. Ricalcolare la firma attesa
$rawBody = $request->getContent(); // RAW body, non parsed!
$payloadToSign = $timestamp . '.' . $rawBody;
$expectedSignature = hash_hmac('sha256', $payloadToSign, $webhookSecret);
// 5. Confrontare in modo timing-safe
return hash_equals($expectedSignature, $receivedSignature);
}
// Utilizzo
if (!verifyWebhookSignature($request, env('WEBHOOK_SECRET'))) {
return response()->json(['error' => 'Invalid signature'], 401);
}
// Elaborare il webhook
$payload = json_decode($request->getContent(), true);
// ...Esempio implementazione Node.js
const crypto = require('crypto');
function verifyWebhookSignature(req, webhookSecret) {
// 1. Ottenere gli header
const timestamp = req.headers['webhook-timestamp'];
const signature = req.headers['webhook-signature'];
if (!timestamp || !signature) {
return false; // 401 Unauthorized
}
// 2. Verificare freschezza del timestamp (max 5 minuti)
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
return false; // Timestamp troppo vecchio
}
// 3. Estrarre la firma v1
const v1Match = signature.match(/v1=([a-f0-9]+)/);
if (!v1Match) {
return false;
}
const receivedSignature = v1Match[1];
// 4. Ricalcolare la firma attesa
const rawBody = req.rawBody; // Deve essere il body RAW, non parsed!
const payloadToSign = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(payloadToSign)
.digest('hex');
// 5. Confrontare in modo timing-safe
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(receivedSignature)
);
}
// Utilizzo con Express
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
req.rawBody = req.body.toString('utf8');
if (!verifyWebhookSignature(req, process.env.WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Elaborare il webhook
const payload = JSON.parse(req.rawBody);
// ...
res.json({ received: true });
});Note importanti
-
Usare sempre il body RAW: Non parsare il JSON prima di verificare la firma, altrimenti la formattazione potrebbe cambiare e la verifica fallirà
-
Confronto timing-safe: Utilizzare sempre funzioni di comparazione sicure per prevenire timing attacks
-
Tolleranza temporale: La finestra di 5 minuti è un buon compromesso tra sicurezza e tolleranza per problemi di sincronizzazione oraria
-
Rotazione chiavi: Il formato con versione (
v1=) permette di supportare più algoritmi di firma in futuro senza rompere la retrocompatibilità -
Logging: Registrare i tentativi falliti di verifica per individuare possibili attacchi
Payload del webhook
Il payload JSON inviato contiene informazioni sulla spedizione:
{
"event": "tracking.updated",
"timestamp": 1733678400,
"data": {
"tracking_number": "ABC123456789",
"carrier": "gls",
"status": "in_transit",
"status_description": "In transito",
"last_update": "2024-12-08T10:30:00Z",
"events": [
{
"timestamp": "2024-12-08T10:30:00Z",
"status": "in_transit",
"location": "Milano",
"description": "Pacco in transito"
}
]
}
}Eventi disponibili
| Evento | Descrizione |
|---|---|
tracking.updated | Aggiornamento stato spedizione |
tracking.delivered | Spedizione consegnata |
tracking.exception | Eccezione nella spedizione |
shipment.created | Nuova spedizione creata |
Risposta richiesta
Il vostro endpoint deve rispondere con:
- 200 OK se il webhook è stato elaborato con successo
- 401 Unauthorized se la firma non è valida
- 500 Internal Server Error in caso di errori nell’elaborazione
Il sistema riproverà automaticamente l’invio in caso di errore (con backoff esponenziale).