11 min di letturaIngegneria

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

Come verificare una firma webhook con HMAC-SHA256: corpo grezzo, confronto a tempo costante, finestra anti-replay e rotazione del segreto, con codice per Node, Python, Go e n8n.

Marius Voß
DevRel · edge infra
Copertina in stile pixel che mostra come verificare gli header della firma webhook: un timestamp e il corpo grezzo vengono sottoposti a hash con HMAC-SHA256 in un valore esadecimale v1=, confrontato a tempo costante accanto a una chiave pixelata

Per verificare una firma webhook, ricalcola un HMAC su esattamente i byte firmati dal mittente, usando il segreto che condividi con esso, e confronta il tuo valore con l'header della firma a tempo costante. Poi controlla che il timestamp firmato sia recente. Per i webhook Elido questo significa HMAC-SHA256 con chiave pari all'intero segreto 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 derivano dai dettagli che lo circondano: un parser del corpo eseguito troppo presto, un segreto decodificato, un digest esadecimale confrontato con uno base64. Questo articolo illustra la verifica delle firme webhook HMAC in generale, poi codice Elido funzionante per Node, Python, Go e un nodo Code di n8n, oltre alla finestra anti-replay e alla rotazione del segreto che molte guide rapide omettono.

Se non hai ancora configurato un endpoint, inizia con webhook per gli eventi dei link, che elenca ogni tipo di evento e l'involucro del payload. Questa pagina prosegue dal momento in cui una richiesta firmata arriva sul tuo server.

Come funzionano le firme webhook HMAC

Un endpoint webhook è un URL pubblico. Chiunque lo trovi può inviare via POST un corpo JSON che sembra un evento reale, quindi il ricevitore ha bisogno di una prova dell'origine. HMAC la offre a basso costo: mittente e ricevitore condividono un segreto, il mittente calcola HMAC-SHA256(secret, message) e inserisce il risultato in un header, mentre il ricevitore esegue lo stesso calcolo e confronta. Senza il segreto, nessuno può produrre un valore corrispondente, e modificare un solo byte del messaggio cambia l'intero digest.

Tre dettagli differiscono tra i provider e ciascuno interrompe la verifica se viene gestito male:

  1. Cosa viene inserito nel messaggio. GitHub firma solo il corpo grezzo. Stripe ed Elido firmano un timestamp, un punto e il corpo. La specifica Standard Webhooks firma un ID messaggio, il timestamp e il corpo.
  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 segreto dopo il suo prefisso. Elido non lo fa: la chiave è l'intera stringa whsec_ come byte UTF-8.

Inserire il timestamp nel messaggio firmato è importante. Impedisce a un aggressore di abbinare un vecchio corpo firmato validamente a un header timestamp recente, ed è ciò che rende applicabile una finestra anti-replay.

Come verificare una firma webhook: Elido unisce il timestamp Unix, un punto e il corpo grezzo, ne calcola l'hash con HMAC-SHA256 usando il segreto whsec_ e invia v1= esadecimale in X-Elido-Signature; il ricevitore ricalcola lo stesso valore dai byte grezzi e dall'header timestamp, poi confronta a tempo costante

Cosa firma Elido e quali header lo trasportano

Ogni consegna a un endpoint event o siem è un POST con Content-Type: application/json e un corpo nella forma {"type", "workspace_id", "data", "timestamp"}. I tipi di endpoint orientati alla chat (Discord, Telegram, Sentry) si autenticano tramite il loro URL e non includono header HMAC, quindi tutto quanto segue si applica solo ai primi due tipi.

HeaderValoreCome usarlo
X-Elido-Signaturev1= + 64 caratteri esadecimali minuscoliConfrontalo con il valore calcolato
X-Webhook-SignatureStesso valore di sopraAlias precedente; leggi uno dei due, non entrambi
X-Webhook-TimestampSecondi Unix, ad es. 1789000000Parte del messaggio firmato; controllane l'età
X-Elido-Signature-Previousv1= + esadecimale, firmato con il vecchio segretoPresente solo durante una finestra di tolleranza della rotazione
X-Webhook-EventNome dell'evento, ad es. link.createdInstrada l'evento (dopo la verifica)
X-Webhook-DeliveryID numerico di consegna, stabile tra i tentativiChiave di deduplicazione per elaborazione idempotente

Il segreto viene generato per te quando viene creato l'endpoint: whsec_ seguito da 64 caratteri esadecimali. Viene restituito una sola volta nella risposta di creazione e mai più, quindi inseriscilo subito nel tuo archivio dei segreti.

Ecco un vettore di test su cui puoi eseguire 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 header corretto è:

v1=b9369aa411a8b7ce705bcd5bba112dea9d72d2e787aa88959ff62f33942d1a15

Puoi riprodurlo dalla shell, che è la mia prima mossa ogni volta che un ricevitore non è d'accordo con il mittente:

printf '%s.%s' 1789000000 '{"type":"link.created","workspace_id":42}' \
  | openssl dgst -sha256 -hmac 'whsec_test_only_do_not_use' -r

Qui c'è un'insidia: il campo timestamp del corpo indica quando è avvenuto l'evento. Il timestamp firmato è quello nell'header X-Webhook-Timestamp, impostato quando la richiesta viene inviata. Non confonderli.

Convalidare una firma webhook in Node

In Express la soluzione per la maggior parte dei fallimenti è una riga: monta express.raw() sulla rotta webhook affinché req.body sia un Buffer contenente gli esatti byte ricevuti. Registra questa rotta prima di qualsiasi app.use(express.json()) globale, perché una volta che il parser JSON ha consumato lo stream, il parser grezzo 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 è un ornamento. timingSafeEqual lancia un'eccezione con buffer di lunghezze diverse invece di restituire falso. Se usi l'SDK TypeScript dalla guida rapida API e SDK, webhooks.verify() in @elido/sdk esegue lo stesso HMAC e confronto a tempo costante. Passa però esplicitamente { maxSkewSec: 300 }: senza tale opzione non controlla affatto l'età del timestamp.

Verifica HMAC SHA256 dei webhook in Python e Go

La libreria standard di Python è sufficiente. Con FastAPI, await request.body() restituisce i byte grezzi; in Flask, chiama request.get_data() prima che qualcosa acceda a 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 corpo una sola 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 a una firma non valida e analizzano JSON solo dopo il superamento del controllo. Elido considera qualsiasi risposta diversa da 2xx un tentativo fallito, quindi se il tuo verificatore è errato, le consegne autentiche si accumulano come 401 nel registro delle consegne dell'endpoint. È il primo posto da controllare dopo una distribuzione.

Vuoi vederlo con traffico reale? Crea un endpoint in uno spazio di lavoro dalla pagina della funzionalità webhook e puntalo a un tunnel locale; il registro delle consegne mostra il codice di stato restituito dal tuo verificatore per ogni tentativo.

Verificare la firma webhook in un nodo Code n8n

n8n può eseguire lo stesso controllo senza alcun servizio aggiuntivo. Attiva l'opzione Raw Body nel nodo Webhook, che memorizza la richiesta intatta come dati binari, quindi aggiungi un nodo Code subito dopo. Il modulo crypto integrato è consentito nel nodo Code n8n, quindi questo codice 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 interrompe l'esecuzione, quindi nulla a valle viene eseguito su un evento falsificato. Un'insidia in self-hosting: se la tua istanza imposta N8N_BLOCK_ENV_ACCESS_IN_NODE=true, il nodo Code non può leggere $env, il segreto risulta vuoto e ogni consegna fallisce il controllo. La guida a n8n self-hosted tratta il lato reverse proxy, e l'articolo sull'accorciatore di URL per n8n mostra cosa creare quando arrivano gli eventi.

Finestre anti-replay e rotazione del segreto

Una firma valida dimostra chi ha inviato una richiesta, non quando. Chiunque catturi una consegna firmata, da una riga di registro o da un proxy configurato male, potrebbe inviarla di nuovo una settimana dopo e l'HMAC continuerebbe a corrispondere. Il controllo del timestamp chiude questa lacuna, ed è compito tuo: il processo di consegna Elido firma il timestamp ma non applica alcuna finestra dal tuo lato. Io uso 300 secondi. Mantieni sincronizzato l'orologio del ricevitore con NTP, perché un server il cui orologio sfasa di alcuni minuti inizierà a rifiutare traffico legittimo.

I tentativi ripetuti non attivano la finestra. Ogni tentativo riceve un nuovo X-Webhook-Timestamp e una nuova firma, mentre X-Webhook-Delivery rimane lo stesso. Questa separazione ti offre entrambe le difese: il timestamp limita per quanto tempo una richiesta catturata resta utilizzabile, e un indice univoco sull'ID di consegna impedisce che un nuovo tentativo legittimo venga elaborato due volte. L'articolo su limiti di frequenza e idempotenza tratta lo stesso modello sul lato API in ingresso.

La rotazione avviene tramite POST /v1/workspaces/{workspace_id}/webhooks/{id}/rotate-secret, oppure il pulsante Rotate nella pagina dell'endpoint. La risposta contiene una volta sola il nuovo segreto, oltre a grace_window_days (7) e previous_expires_at. Per quei sette giorni ogni consegna include due firme:

  • X-Elido-Signature, creata con il nuovo segreto
  • X-Elido-Signature-Previous, creata con il vecchio segreto

Ecco perché ogni frammento precedente controlla entrambi gli header rispetto all'unico segreto in suo possesso. Un ricevitore ancora in esecuzione con il vecchio segreto corrisponde al secondo header; dopo aver distribuito quello nuovo, corrisponde al primo. Nel frattempo non fallisce nulla. Considera però l'header della chiave precedente come un ponte anziché una garanzia e distribuisci il nuovo segreto all'inizio della settimana.

Cronologia della rotazione del segreto webhook: prima della rotazione viene inviato solo X-Elido-Signature; dopo rotate-secret, per una finestra di tolleranza di sette giorni, le consegne includono X-Elido-Signature con la nuova chiave e X-Elido-Signature-Previous con la vecchia, così un ricevitore con una delle due chiavi verifica; dopo la finestra firma solo la nuova chiave

Perché la verifica della firma webhook fallisce

Quando aiuto qualcuno a eseguire il debug, è quasi sempre lo stesso breve elenco. Seguilo nell'ordine:

  1. JSON serializzato di nuovo. JSON.stringify(req.body) o json.dumps(payload) producono byte diversi da quelli su cui il mittente ha calcolato l'hash: ordine delle chiavi, spaziatura, barre oblique con escape, escape Unicode. Calcola l'hash sul corpo grezzo. Se il tuo framework lo ha già analizzato, correggi l'ordine del middleware invece di tentare di ricostruire la stringa.
  2. Byte della chiave errati. Elido usa l'intera stringa whsec_... come chiave HMAC. Rimuovere il prefisso, decodificare il resto in esadecimale o decodificarlo in base64 (come fanno le librerie Standard Webhooks) ti fornisce una chiave diversa. Anche una nuova riga finale da echo in un file di segreti fa lo stesso, motivo per cui l'esempio Python chiama .strip().
  3. Codifica non corrispondente. Confronta v1= più esadecimale minuscolo con l'header. Un digest base64, una stringa esadecimale maiuscola o un prefisso mancante non corrispondono mai.
  4. Timestamp errato. Usa la stringa dell'header X-Webhook-Timestamp, non il campo timestamp del corpo e nemmeno un numero che hai analizzato e riformattato.

Altri due aspetti minori: confrontare con == funziona ma rivela informazioni temporali, quindi usa la funzione a tempo costante fornita dal tuo linguaggio; anche un proxy che decomprime o ricodifica i corpi interrompe la verifica, sebbene sia raro per semplici POST JSON.

Quando le due parti continuano a non essere d'accordo, registra il timestamp, la lunghezza del corpo e i primi caratteri esadecimali del digest, poi esegui la riga openssl precedente sugli stessi input. La parte che corrisponde a openssl è quella corretta. Per vedere dove si inserisce la firma tra gli altri controlli utili da eseguire su qualsiasi provider, consulta la checklist di sicurezza per accorciatori di URL.

Leggi l'articolo fondamentale: webhook per gli eventi dei link.

Correlati sul blog

Domande frequenti

Come verifico una firma webhook?

Ricalcola l'HMAC su esattamente ciò che il mittente ha firmato, usando il segreto condiviso, e confronta il risultato con l'header della firma a tempo costante. Per Elido significa HMAC-SHA256 sul valore X-Webhook-Timestamp, un punto e il corpo grezzo, codificato in esadecimale con prefisso v1=. Rifiuta la richiesta se non corrisponde nulla o il timestamp non è recente.

Perché la verifica della firma webhook continua a non riuscire?

Quasi sempre perché hai calcolato l'hash su byte diversi da quelli del mittente. Un parser del corpo JSON è stato eseguito prima e hai serializzato di nuovo l'oggetto, oppure hai decodificato il segreto, oppure hai confrontato esadecimale con base64. Calcola l'hash sui byte grezzi della richiesta, usa la stringa del segreto esattamente come fornita e registra entrambi i valori fianco a fianco.

Che cos'è HMAC in un webhook?

HMAC è un hash con chiave: il mittente combina un segreto condiviso con te in un hash SHA-256 del messaggio. Solo chi possiede il segreto può produrre un valore corrispondente, quindi una firma valida dimostra che la richiesta proviene dal mittente e che il corpo non è stato modificato durante il percorso.

Come prevengo gli attacchi replay ai webhook?

Firma il timestamp insieme al corpo e rifiuta qualsiasi richiesta con un timestamp più vecchio di qualche minuto; cinque minuti sono una finestra comune. Poi memorizza l'ID di consegna e salta gli ID già elaborati. Elido firma il timestamp ma lascia il controllo della freschezza al tuo ricevitore.

Devo usare esadecimale o base64 per una firma webhook HMAC-SHA256?

Usa ciò che documenta il mittente, perché le due codifiche dello stesso digest non risultano mai uguali nel confronto. Elido invia esadecimale minuscolo dopo un prefisso v1=. Shopify e la specifica Standard Webhooks usano base64, mentre GitHub usa esadecimale dopo sha256=. Codifica il digest nello stesso modo prima di confrontarlo.

Come ruoto un segreto webhook senza perdere eventi?

Usa un mittente che firmi temporaneamente con entrambe le chiavi. Dopo aver ruotato un segreto endpoint Elido, ogni consegna include X-Elido-Signature con la nuova chiave e X-Elido-Signature-Previous con quella vecchia per sette giorni. Accetta entrambi gli header, distribuisci il nuovo segreto e quello vecchio scadrà da solo.

Prova Elido

Incolla un URL, ottieni un link breve

Senza registrazione. Il link vive 30 giorni. Iscriviti per conservarlo.

Gratis, nessuna registrazione richiesta · 2 al giorno

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