12 min di letturaIngegneria

Verificare le firme dei webhook: HMAC-SHA256 in Node, Go

Verificare la firma di un webhook con HMAC-SHA256: body grezzo, confronto a tempo costante, anti-replay e rotazione del secret, con codice Node.

Marius Voß
DevRel · edge infra

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:

  1. 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.
  2. Come è codificato il digest. Esadecimale o base64, con un prefisso di schema come v1= o sha256=.
  3. 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.

Come verificare la firma di un webhook: Elido unisce il timestamp Unix, un punto e il body grezzo, ne calcola l'hash con HMAC-SHA256 sotto il secret whsec_ e invia l'esadecimale v1= in X-Elido-Signature; il ricevitore ricalcola lo stesso valore dai byte grezzi e dall'header del timestamp. Il modulo per aggiungere un endpoint webhook di Elido con l'URL dell'endpoint, la selezione degli eventi e il segreto di firma che i destinatari usano per la verifica HMAC

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.

HeaderValoreCosa farne
X-Elido-Signaturev1= + 64 caratteri esadecimali minuscoliConfrontalo con il tuo valore calcolato
X-Webhook-SignatureStesso valore di sopraAlias più vecchio; leggi l'uno o l'altro, non entrambi
X-Webhook-TimestampSecondi Unix, es. 1789000000Parte del messaggio firmato; controlla la sua età
X-Elido-Signature-Previousv1= + esadecimale, firmato con il vecchio secretPresente solo durante una finestra di grazia della rotazione
X-Webhook-EventNome dell'evento, es. link.createdInstrada l'evento (dopo la verifica)
X-Webhook-DeliveryID numerico della consegna, stabile tra i retryChiave 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ù valori v1= in un unico header Stripe-Signature, firma timestamp.body con 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 in X-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-timestamp e webhook-signature, firma id.timestamp.body, codifica in base64 con un prefisso v1, e lascia a te il valore di tolleranza (specifica). L'X-Webhook-Delivery di 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 secret
  • X-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.

Linea temporale della rotazione del secret di un webhook: prima della rotazione viene inviato solo X-Elido-Signature; dopo rotate-secret, per una finestra di grazia di sette giorni, le consegne portano X-Elido-Signature con la nuova chiave e X-Elido-Signature-Previous con la vecchia.

Perché la verifica della firma del webhook fallisce

Quando aiuto qualcuno a fare debug, è quasi sempre la stessa breve lista. Percorrila in ordine:

  1. JSON riserializzato. JSON.stringify(req.body) o json.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.
  2. 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 un echo in un file di secret fa lo stesso, ed è per questo che l'esempio Python chiama .strip().
  3. 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.
  4. Il timestamp sbagliato. Usa la stringa dell'header X-Webhook-Timestamp, non il campo timestamp del 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

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.

Prova Elido

Accorciatore di URL ospitato nell'UE: domini personalizzati, analisi approfondite e API aperta. Piano gratuito - senza carta di credito.

Tag
verify webhook signature
webhook signature verification
hmac webhook
webhook hmac sha256
webhook replay attack
webhook secret rotation

Continua a leggere