Per verificare la firma di un webhook, ricalcola un HMAC esattamente sui byte che il mittente ha firmato, usando il secret che condividi con lui, e confronta il tuo valore con l'header della firma a tempo costante. Poi controlla che il timestamp firmato sia recente. Per i webhook di Elido significa HMAC-SHA256 con chiave l'intero secret whsec_..., su {X-Webhook-Timestamp}.{raw body}, codificato in esadecimale, con prefisso v1=, e confrontato con X-Elido-Signature.
Questo è l'intero algoritmo. I fallimenti nascono dai dettagli intorno: un parser del body che è passato troppo presto, un secret che è stato decodificato, un digest esadecimale confrontato con uno base64. Questo post tratta la verifica della firma HMAC dei webhook in generale, poi codice Elido funzionante per Node, Python, Go e un nodo Code di n8n, più la finestra anti-replay e la rotazione del secret che la maggior parte delle guide rapide omette.
Se non hai ancora configurato un endpoint, parti dai webhook per gli eventi dei link, che elenca ogni tipo di evento e l'envelope del payload. Questa pagina riprende da quando una richiesta firmata arriva sul tuo server.
Come funzionano le firme HMAC dei webhook
Un endpoint webhook è un URL pubblico. Chiunque lo trovi può inviare con POST un body JSON che sembra un evento reale, quindi il ricevitore ha bisogno di una prova di origine. L'HMAC la fornisce a basso costo: mittente e ricevitore condividono un secret, il mittente calcola HMAC-SHA256(secret, message) e mette il risultato in un header, e il ricevitore esegue lo stesso calcolo e confronta. Senza il secret, nessuno può produrre un valore corrispondente, e cambiare un solo byte del messaggio cambia l'intero digest.
Tre dettagli differiscono tra i provider, e ciascuno rompe la verifica se sbagliato:
- Cosa entra nel messaggio. GitHub firma solo il body grezzo. Stripe ed Elido firmano un timestamp, un punto e il body. La specifica Standard Webhooks firma un ID del messaggio, il timestamp e il body.
- Come è codificato il digest. Esadecimale o base64, con un prefisso di schema come
v1=osha256=. - Qual è la chiave. Alcuni provider decodificano in base64 il secret dopo il suo prefisso. Elido no: la chiave è l'intera stringa
whsec_come byte UTF-8.
Mettere il timestamp dentro il messaggio firmato conta. Impedisce a un attaccante di abbinare un body vecchio e firmato validamente a un header timestamp fresco, ed è ciò che rende applicabile una finestra anti-replay.
Cosa firma Elido e quali header lo portano
Ogni consegna a un endpoint event o siem è un POST con Content-Type: application/json e un body con la forma {"type", "workspace_id", "data", "timestamp"}. I tipi di endpoint in forma di chat (Discord, Telegram, Sentry) si autenticano tramite il loro URL e non portano header HMAC, quindi tutto ciò che segue vale solo per i primi due tipi.
| Header | Valore | Cosa farne |
|---|---|---|
X-Elido-Signature | v1= + 64 caratteri esadecimali minuscoli | Confrontalo con il tuo valore calcolato |
X-Webhook-Signature | Stesso valore di sopra | Alias più vecchio; leggi l'uno o l'altro, non entrambi |
X-Webhook-Timestamp | Secondi Unix, es. 1789000000 | Parte del messaggio firmato; controlla la sua età |
X-Elido-Signature-Previous | v1= + esadecimale, firmato con il vecchio secret | Presente solo durante una finestra di grazia della rotazione |
X-Webhook-Event | Nome dell'evento, es. link.created | Instrada l'evento (dopo la verifica) |
X-Webhook-Delivery | ID numerico della consegna, stabile tra i retry | Chiave di deduplicazione per un'elaborazione idempotente |
Ottieni il segreto alla creazione: whsec_ seguito da 64 caratteri esadecimali. Viene restituito una sola volta nella risposta di creazione e mai più, quindi va subito nel tuo archivio dei segreti.
Ecco un vettore di test su cui puoi provare il tuo codice: con il segreto whsec_test_only_do_not_use, il timestamp 1789000000 e il corpo {"type":"link.created","workspace_id":42}, il valore corretto dell'header è:
v1=b9369aa411a8b7ce705bcd5bba112dea9d72d2e787aa88959ff62f33942d1a15
Puoi riprodurlo da una shell, ed è la mia prima mossa ogni volta che un ricevitore non concorda con il mittente:
printf '%s.%s' 1789000000 '{"type":"link.created","workspace_id":42}' \
| openssl dgst -sha256 -hmac 'whsec_test_only_do_not_use' -r
Una trappola: il campo timestamp del corpo è il momento in cui l'evento è avvenuto, mentre il timestamp firmato è quello nell'header X-Webhook-Timestamp, impostato quando la richiesta viene inviata. Non confonderli.
Validare la firma di un webhook in Node
In Express la correzione per la maggior parte dei fallimenti è una riga: monta express.raw() sulla route del webhook in modo che req.body sia un Buffer dei byte esatti ricevuti. Registra questa route prima di qualsiasi app.use(express.json()) globale, perché una volta che il parser JSON ha consumato lo stream, il parser raw non ha più nulla da leggere.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.ELIDO_WEBHOOK_SECRET; // the full whsec_... string
const TOLERANCE_SEC = 300;
function matches(expected, got) {
const a = Buffer.from(expected);
const b = Buffer.from(got ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
const app = express();
app.post(
"/webhooks/elido",
express.raw({ type: "application/json" }),
(req, res) => {
const ts = req.get("X-Webhook-Timestamp") ?? "";
if (
!/^\d+$/.test(ts) ||
Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SEC
) {
return res.status(400).send("stale or missing timestamp");
}
const expected =
"v1=" +
createHmac("sha256", SECRET)
.update(`${ts}.`)
.update(req.body)
.digest("hex");
const ok = [
req.get("X-Elido-Signature"),
req.get("X-Elido-Signature-Previous"),
].some((got) => matches(expected, got));
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// enqueue event, then acknowledge fast
res.sendStatus(200);
},
);
Il controllo della lunghezza non è decorazione. timingSafeEqual lancia un errore su buffer di lunghezza diversa invece di restituire false. Se usi l'SDK TypeScript della guida rapida a API e SDK, webhooks.verify() in @elido/sdk esegue lo stesso HMAC e lo stesso confronto timing-safe. Passa però { maxSkewSec: 300 } in modo esplicito: senza quell'opzione non controlla affatto l'età del timestamp.
Verifica webhook HMAC SHA256 in Python e Go
La libreria standard di Python basta. Con FastAPI, await request.body() restituisce i byte grezzi; in Flask, chiama request.get_data() prima che qualsiasi cosa tocchi request.json.
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, HTTPException, Request
SECRET = os.environ["ELIDO_WEBHOOK_SECRET"].strip().encode()
TOLERANCE = 300
app = FastAPI()
def verify(raw: bytes, ts: str, candidates: list) -> bool:
if not ts.isdigit() or abs(time.time() - int(ts)) > TOLERANCE:
return False
digest = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
expected = "v1=" + digest
return any(c and hmac.compare_digest(c, expected) for c in candidates)
@app.post("/webhooks/elido")
async def elido_webhook(request: Request):
raw = await request.body()
h = request.headers
sigs = [h.get("x-elido-signature"), h.get("x-elido-signature-previous")]
if not verify(raw, h.get("x-webhook-timestamp", ""), sigs):
raise HTTPException(status_code=401, detail="bad signature")
event = json.loads(raw)
# enqueue event
return {"ok": True}
In Go, leggi il body una volta, limitane la dimensione e usa hmac.Equal da crypto/hmac, che confronta a tempo costante. Costruisco il messaggio dalla stringa dell'header esattamente come ricevuta invece di riformattare l'intero analizzato.
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"time"
)
const toleranceSec = 300
// Verify returns the raw body when the request carries a valid Elido signature.
func Verify(w http.ResponseWriter, r *http.Request, secret []byte) ([]byte, bool) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
return nil, false
}
tsHeader := r.Header.Get("X-Webhook-Timestamp")
ts, err := strconv.ParseInt(tsHeader, 10, 64)
if err != nil {
return nil, false
}
if age := time.Now().Unix() - ts; age > toleranceSec || age < -toleranceSec {
return nil, false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(tsHeader + "."))
mac.Write(body)
expected := []byte("v1=" + hex.EncodeToString(mac.Sum(nil)))
for _, name := range []string{"X-Elido-Signature", "X-Elido-Signature-Previous"} {
if got := r.Header.Get(name); got != "" && hmac.Equal([]byte(got), expected) {
return body, true
}
}
return nil, false
}
Tutte e tre le versioni rispondono 401 su una firma errata e analizzano il JSON solo dopo che il controllo è superato. Elido conta qualsiasi risposta non 2xx come tentativo fallito, quindi se il tuo verificatore è sbagliato, le consegne genuine si accumulano come 401 nel log delle consegne dell'endpoint. È il primo posto dove guardare dopo un deploy.
Vuoi vederlo con traffico reale? Crea un endpoint in un workspace dalla pagina della funzionalità webhook e puntalo a un tunnel locale; il log delle consegne mostra il codice di stato restituito dal tuo verificatore per ogni tentativo.
Verificare la firma del webhook dentro un nodo Code di n8n
n8n può fare lo stesso controllo senza servizi aggiuntivi. Attiva l'opzione Raw Body nel nodo Webhook, che conserva la richiesta intatta come dati binari, poi aggiungi un nodo Code subito dopo. Il modulo crypto integrato è consentito nel nodo Code di n8n, quindi questo funziona così com'è:
const crypto = require("crypto");
const h = $input.first().json.headers;
const raw = await this.helpers.getBinaryDataBuffer(0, "data");
const ts = h["x-webhook-timestamp"] ?? "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
throw new Error("stale delivery");
}
const expected = Buffer.from(
"v1=" +
crypto
.createHmac("sha256", $env.ELIDO_WEBHOOK_SECRET)
.update(`${ts}.`)
.update(raw)
.digest("hex"),
);
const ok = ["x-elido-signature", "x-elido-signature-previous"].some((name) => {
const got = Buffer.from(h[name] ?? "");
return (
got.length === expected.length && crypto.timingSafeEqual(got, expected)
);
});
if (!ok) throw new Error("bad signature");
return [{ json: JSON.parse(raw.toString("utf8")) }];
Un errore lanciato ferma l'esecuzione, quindi nulla a valle gira su un evento falsificato. Un problema nel self-hosting: se la tua istanza imposta N8N_BLOCK_ENV_ACCESS_IN_NODE=true, il nodo Code non può leggere $env, il secret risulta vuoto e ogni consegna fallisce il controllo. La guida a n8n self-hosted copre il lato reverse proxy, e il post sul nodo n8n per URL shortener mostra cosa costruire una volta che gli eventi arrivano.
Come Elido differisce da Stripe, GitHub e Standard Webhooks
Se hai scritto un verificatore per un altro provider, non copiarlo alla cieca. Controllato sulla documentazione di ciascun fornitore il 2026-10-25:
- Stripe mette
t=e uno o più valoriv1=in un unico headerStripe-Signature, firmatimestamp.bodycon HMAC-SHA256 in esadecimale, e le sue librerie usano per impostazione predefinita una tolleranza di cinque minuti (documentazione Stripe). Elido separa il timestamp nel proprio header. - GitHub firma solo il body grezzo, invia
sha256=più esadecimale inX-Hub-Signature-256, e ti dice di usare un confronto a tempo costante (documentazione GitHub). Non c'è alcun timestamp da controllare. - Standard Webhooks usa
webhook-id,webhook-timestampewebhook-signature, firmaid.timestamp.body, codifica in base64 con un prefissov1,e lascia a te il valore di tolleranza (specifica). L'X-Webhook-Deliverydi Elido svolge il ruolo dell'ID, ma non fa parte del messaggio firmato.
Lo schema di Elido è quello di signing.Sign in api-core: il messaggio è il timestamp Unix, un punto e il body, la chiave è la stringa del secret come conservata, e il risultato è v1= più esadecimale minuscolo. Elido non ha un evento link.clicked, quindi non aspettarti consegne a livello di clic su questo canale.
Finestre anti-replay e rotazione del secret
Una firma valida prova chi ha inviato una richiesta, non quando. Qualcuno che cattura una consegna firmata, da una riga di log o da un proxy mal configurato, potrebbe inviarla di nuovo una settimana dopo e l'HMAC corrisponderebbe comunque. Il controllo del timestamp chiude quel varco, ed è compito tuo: il worker di consegna di Elido firma il timestamp ma non applica alcuna finestra dal tuo lato. Io uso 300 secondi. Tieni sincronizzato con NTP l'orologio del ricevitore, dato che un server che deriva di qualche minuto inizierà a rifiutare traffico onesto.
I retry non fanno scattare la finestra. Ogni tentativo riceve un X-Webhook-Timestamp fresco e una nuova firma, mentre X-Webhook-Delivery resta lo stesso. Questa divisione ti dà entrambe le difese: il timestamp limita per quanto tempo una richiesta catturata resta utilizzabile, e un indice univoco sull'ID della consegna impedisce che un retry legittimo venga elaborato due volte. Il post su rate limit e idempotenza tratta lo stesso schema dal lato dell'API in ingresso.
La rotazione funziona tramite POST /v1/workspaces/{workspace_id}/webhooks/{id}/rotate-secret, o il pulsante Rotate nella pagina dell'endpoint. La risposta contiene il nuovo secret una sola volta, più grace_window_days (7) e previous_expires_at. Per quei sette giorni ogni consegna porta due firme:
X-Elido-Signature, creata con il nuovo secretX-Elido-Signature-Previous, creata con il vecchio
Per questo ogni snippet qui sopra controlla entrambi gli header con l'unico secret che possiede. Un ricevitore che usa ancora il vecchio secret corrisponde al secondo header; dopo che distribuisci quello nuovo, corrisponde al primo. Nel mezzo non fallisce nulla. Tratta però l'header della chiave precedente come un ponte e non come una garanzia, e distribuisci il nuovo secret presto nella settimana.
Perché la verifica della firma del webhook fallisce
Quando aiuto qualcuno a fare debug, è quasi sempre la stessa breve lista. Percorrila in ordine:
- JSON riserializzato.
JSON.stringify(req.body)ojson.dumps(payload)produce byte diversi da quelli su cui il mittente ha calcolato l'hash: ordine delle chiavi, spaziatura, slash con escape, escape unicode. Calcola l'hash sul body grezzo. Se il tuo framework l'ha già analizzato, correggi l'ordine dei middleware invece di provare a ricostruire la stringa. - I byte sbagliati della chiave. Elido usa l'intera stringa
whsec_...come chiave HMAC. Rimuovere il prefisso, decodificare in esadecimale il resto o decodificarlo in base64 (come fanno le librerie Standard Webhooks) ti dà una chiave diversa. Un newline finale dovuto a unechoin un file di secret fa lo stesso, ed è per questo che l'esempio Python chiama.strip(). - Discrepanza di codifica. Confronta
v1=più esadecimale minuscolo con l'header. Un digest base64, una stringa esadecimale maiuscola o un prefisso mancante non corrispondono mai. - Il timestamp sbagliato. Usa la stringa dell'header
X-Webhook-Timestamp, non il campotimestampdel body, e non un numero che hai analizzato e riformattato.
Altri due più piccoli: confrontare con == funziona ma lascia trapelare i tempi, quindi usa la funzione a tempo costante che il tuo linguaggio fornisce; e un proxy che decomprime o ricodifica i body romperà anch'esso la verifica, anche se è raro per semplici POST JSON.
Quando i due lati continuano a non concordare, registra il timestamp, la lunghezza del body e i primi caratteri esadecimali del tuo digest, poi esegui la riga openssl di prima sugli stessi input. Il lato che corrisponde a openssl è quello corretto. Per capire dove si colloca la firma tra gli altri controlli da verificare su qualsiasi provider, vedi la checklist di sicurezza degli URL shortener.
Leggi il cornerstone: webhook per gli eventi dei link.
Correlati sul blog
- Webhook per gli eventi dei link - tipi di evento, envelope del payload e policy dei retry.
- Webhook vs polling per il tracciamento dei clic - quando il push batte il pull, e quando no.
- Automazione dei link self-hosted con n8n - reverse proxy, queue mode e controlli di firma in un unico stack.
- API dello URL shortener: rate limit, retry, idempotenza - gli schemi di deduplicazione dall'altra direzione.
- Checklist di sicurezza degli URL shortener - nove controlli da verificare su qualsiasi provider.
Domande frequenti
Come verifico la firma di un webhook?
Ricalcola l'HMAC esattamente su ciò che il mittente ha firmato, usando il secret condiviso, e confronta il risultato con l'header della firma a tempo costante. Per Elido significa HMAC-SHA256 sul valore di X-Webhook-Timestamp, un punto e il body grezzo, codificato in esadecimale con il prefisso v1=. Rifiuta la richiesta se nulla corrisponde o se il timestamp è scaduto.
Perché la verifica della firma del mio webhook continua a fallire?
Quasi sempre perché hai calcolato l'hash su byte diversi da quelli del mittente. Un parser del body JSON è passato prima e hai riserializzato l'oggetto, oppure hai decodificato il secret, oppure hai confrontato un esadecimale con un base64. Calcola l'hash sui byte grezzi della richiesta, usa la stringa del secret esattamente come emessa e registra i due valori affiancati.
Cos'è l'HMAC in un webhook?
L'HMAC è un hash con chiave: il mittente mescola un secret che condivide con te in un hash SHA-256 del messaggio. Solo chi possiede il secret può produrre un valore corrispondente, quindi una firma valida prova che la richiesta proviene dal mittente e che il body non è stato modificato lungo il percorso.
Come prevengo gli attacchi replay sui webhook?
Firma il timestamp insieme al body e rifiuta qualsiasi richiesta il cui timestamp abbia più di pochi minuti; cinque minuti è una finestra comune. Poi conserva l'ID della consegna e salta gli ID già elaborati. Elido firma il timestamp ma lascia il controllo di freschezza al tuo ricevitore.
Devo usare esadecimale o base64 per la firma di un webhook HMAC-SHA256?
Quello che documenta il mittente, perché le due codifiche dello stesso digest non risultano mai uguali. Elido invia esadecimale minuscolo dopo un prefisso v1=. Shopify e la specifica Standard Webhooks usano base64, e GitHub usa esadecimale dopo sha256=. Codifica il tuo digest allo stesso modo prima di confrontare.
Come ruoto il secret di un webhook senza perdere eventi?
Usa un mittente che firmi con entrambe le chiavi per un certo periodo. Dopo che ruoti il secret di un endpoint Elido, ogni consegna porta X-Elido-Signature con la nuova chiave e X-Elido-Signature-Previous con la vecchia per sette giorni. Accetta uno qualsiasi dei due header, distribuisci il nuovo secret, e il vecchio scade da solo.
Prova Elido
Incolla un URL, ottieni un link breve
Senza registrazione. Il link vive 30 giorni. Iscriviti per conservarlo.
Senza registrazione. Crea un account gratuito per 50 link al mese.