Verifica della firma webhook fallita? Trova la causa
Un errore di verifica della firma webhook fallita significa una cosa sola: il digest calcolato dal tuo codice non è uguale al digest presente nell’header della richiesta. Il messaggio si esaurisce qui. Non dice niente sui permessi, niente sulle scadenze, e quasi mai si tratta di un bug nell’SDK del provider. Qualcosa differisce tra i byte di cui il provider ha calcolato l’hash e i byte su cui l’hai calcolato tu.
Quattro input decidono l’esito: quali byte sono stati firmati, quali byte di chiave sono stati usati, quale algoritmo di hash ha girato e in quale codifica testuale hai fatto il confronto. Sbagliane uno qualsiasi e il fallimento ha esattamente lo stesso aspetto. L’errore non porta con sé alcun indizio su quale dei quattro sia stato, quindi il lavoro consiste nel restringere lo spazio degli input, non nel leggere il messaggio con più attenzione.
Scegli il ramo da cui partire:
La firma non coincide? Tre rami:
├─ Il tuo framework ha analizzato il JSON prima che lo vedessi? → Sezione 3
├─ Il valore dell'header ha un prefisso, o sembra base64? → Sezione 4
└─ L'header del provider contiene un timestamp? → Sezione 2
Ogni sezione finisce con qualcosa che puoi eseguire sul tuo payload.
1. Che cosa ti dice davvero una firma che non coincide
La verifica è il confronto di due stringhe di byte. Quando fallisce, esattamente una di quattro cose è sbagliata, e le quattro sono indipendenti tra loro.
Quali byte sono stati firmati. Il provider ha calcolato l’hash di una sequenza precisa di byte. Magari è il corpo della richiesta da solo, magari è un timestamp incollato davanti al corpo. Se il tuo framework ha analizzato il JSON e ti ha consegnato un oggetto, quei byte non li hai più e non puoi ricostruirli in modo affidabile. È la Sezione 3, e con ampio margine è la causa più comune.
Quali byte di chiave sono stati usati. La stessa stringa di segreto si può leggere come testo UTF-8, come hex o come base64, e ogni lettura produce una chiave diversa. Lo stesso vale per un segreto con un a capo in più che il caricatore di configurazione si è tenuto. In questa dimensione si nasconde un secondo fallimento: il segreto può essere del tutto sbagliato, non una lettura sbagliata di quello giusto, e di questo parla la Sezione 6.
In quale codifica hai fatto il confronto. Un digest è 32 byte grezzi per SHA-256. Hex e base64 sono due modi di scrivere quegli stessi byte come testo, e non si assomigliano mai. Confrontane uno contro l’altro e ottieni un hmac signature mismatch permanente, anche se i byte sottostanti coincidono.
Quale algoritmo di hash ha girato. Quasi tutti i provider usano SHA-256 e lo scrivono nella documentazione, quindi questa dimensione di solito non ti costa niente. L’eccezione che vale la pena conoscere è GitHub: ogni consegna porta X-Hub-Signature (HMAC-SHA1) accanto a X-Hub-Signature-256 (HMAC-SHA256), e la documentazione di GitHub dice che l’header SHA-1 resta solo per compatibilità con il passato, e raccomanda la variante 256. Leggi quello sbagliato e te ne accorgi dalla lunghezza prima che dai byte. Il corpo della Sezione 2, firmato con lo stesso segreto sotto SHA-1, è sha1=ba2954d180839d8170b08b32cd38483775aaae96: 40 caratteri hex contro i 64 del suo digest SHA-256.
Tieni separate queste quattro dimensioni mentre fai il debug. Il modo più rapido per isolarne una è calcolare il digest fuori dalla tua applicazione, a partire da input che controlli tu: incolla un corpo e un segreto nel generatore HMAC e guarda cosa esce. Gira interamente nel tuo browser e il segreto non lascia la pagina, quindi puoi incollarci senza rischi anche un signing secret di produzione. HMAC usa la stessa primitiva SHA-256 di un semplice hash SHA-256, solo con la tua chiave in più, quindi se riesci a riprodurre a mano il valore del provider la crittografia è a posto e il bug sta nella gestione della richiesta.
2. Che cosa firmano i quattro grandi provider
L’assunzione che affonda la maggior parte delle integrazioni è che ogni provider firmi il corpo della richiesta e nient’altro. Due dei quattro più grandi non lo fanno. Ecco su cosa calcola l’hash ognuno di loro, verificato sulla documentazione dei provider:
| Provider | Header | Stringa firmata | Codifica | Prefisso del valore | Segreto | Tolleranza timestamp |
|---|---|---|---|---|---|---|
| Stripe | Stripe-Signature | {timestamp} + . + rawBody | hex | t=…,v1=…,v0=… | signing secret dell’endpoint (prefisso whsec_) | 5 minuti (300 secondi) |
| GitHub | X-Hub-Signature-256 | rawBody (nessun prefisso) | hex | sha256= | token segreto del webhook | nessuna (non manda timestamp) |
| Slack | X-Slack-Signature + X-Slack-Request-Timestamp | v0: + {timestamp} + : + rawBody | hex | v0= | signing secret | 5 minuti |
| Shopify | X-Shopify-Hmac-SHA256 | rawBody | base64 | nessuno | client secret dell’app (non un segreto webhook separato) | nessuna |
Quei quattro coprono per caso tre assi ortogonali. La stringa firmata è o il corpo da solo o una concatenazione con il timestamp, e cambia anche il separatore: Stripe usa . mentre Slack usa :. La codifica è hex per tre e base64 per uno. Il segreto arriva da una credenziale webhook dedicata per tre, e dal client secret dell’app per Shopify, che è il dettaglio che si sbaglia più spesso, perché nell’interfaccia di amministrazione c’è un campo etichettato “webhook” che non è quello che ti serve. Sulla terminologia: in italiano la stessa cosa si trova scritta come stringa firmata e come stringa da firmare, e in entrambi i casi si parla dei byte di cui si calcola l’hash, non della firma che ne esce. Nella documentazione tradotta conta la definizione, non l’etichetta.
Per rendere concrete le differenze, ecco un solo corpo firmato in quattro modi con un solo segreto:
body : {"id":42,"event":"user.created"}
secret : whsec_test_secret
ts : 1700000000
| Forma | Valore |
|---|---|
| Stile GitHub | sha256=09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b |
| Stile Shopify | Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs= |
| Stile Stripe | t=1700000000,v1=4b56de5a58122bab8ebbadbed663fbc17d810096d57498f5b24a72f5123b2375 |
| Stile Slack | v0=3faf37337484c62dcd1a6c1ff308d1345c31291e4aac9b44554a99e8e35a1f9c |
Leggi insieme le prime due righe, perché sono lo stesso digest di 32 byte scritto due volte. Sessantaquattro caratteri hex, oppure quarantaquattro caratteri base64 padding incluso. Niente nelle due stringhe suggerisce che siano uguali, ed è per questo che confrontare tra codifiche diverse produce una discrepanza che sopravvive a ogni controllo “ma il segreto è quello giusto” che ti venga in mente.
Le ultime due righe dimostrano l’altra metà del punto. Stesso corpo, stesso segreto, stesso algoritmo, e nessuno dei due digest assomiglia a quello di GitHub, perché la stringa di cui si calcola l’hash ora inizia con un timestamp. La maggior parte delle segnalazioni di stripe webhook signature verification failed si riduce a questa riga: il codice ha calcolato l’hash del corpo da solo e non ha mai messo davanti il valore t e il punto. Riproduci tutti e quattro i valori nel generatore HMAC modificando solo il campo del messaggio e cambiando il formato di output, e il meccanismo smette di essere astratto.
Una conseguenza pratica della colonna della tolleranza: un digest Stripe o Slack è valido solo per qualche minuto, quindi non puoi catturare una firma oggi e riproporla in un test domani. Le firme di GitHub e Shopify sono stabili per sempre, il che le rende molto più facili da diagnosticare e significa anche che alla protezione dal replay devi pensare tu.
3. Il problema del corpo grezzo
La maggior parte delle segnalazioni di webhook signature verification failed riporta a questa sezione, ed è la parte che quasi nessun articolo di troubleshooting copre.
Il tuo framework ha già distrutto i byte
I web framework sono fatti per risparmiarti il parsing. È proprio quella comodità a rompere la verifica della firma, perché quando il tuo handler entra in azione i byte originali non ci sono più.
express.json() legge lo stream della richiesta, lo analizza e sostituisce req.body con un oggetto JavaScript. Lo stream è consumato e non si può rileggere. In FastAPI, dichiarare un modello Pydantic o un parametro di corpo dict significa che il framework legge e analizza prima che la tua funzione venga chiamata. Rails popola params dal corpo JSON attraverso un middleware che gira prima della action del controller. Il convertitore Jackson di Spring trasforma il corpo nella tua classe DTO, e per default lo stream di input di HttpServletRequest sottostante si può leggere una volta sola.
Niente di tutto questo è un bug. Ognuno di questi pezzi fa esattamente quello per cui è stato configurato. Il problema è che una firma copre byte, un oggetto non è byte, e ritrasformare l’oggetto in byte è un’operazione diversa da quella che ha eseguito il provider.
Perché la ri-serializzazione a volte funziona, ed è questa la trappola
Il consiglio che si sente di solito è che la ri-serializzazione cambia i byte. È una spiegazione incompleta, e la metà mancante è ciò che rende questo fallimento così difficile da diagnosticare. A volte non cambia proprio niente.
Ecco JSON.stringify(JSON.parse(body)) === body misurato su diverse forme di payload:
| Forma del payload | Byte dopo il round-trip | Cambiamento |
|---|---|---|
{"id":42,"event":"user.created"} | identici | nessuno, ed è per questo che i test locali passano |
{"amount":1.0} | cambiati | → {"amount":1} |
{"n":1e3} | cambiati | → {"n":1000} |
{"id":12345678901234567890} | cambiati | → {"id":12345678901234567000} (precisione persa) |
{"name":"caf\u00e9"} | cambiati | → {"name":"café"} (6 byte diventano 2) |
{"a":1}\n | cambiati | a capo finale ingoiato |
{ "a" : 1 } | cambiati | spazi interni ingoiati |
{"v":-0.0} | cambiati | → {"v":0} |
{"p":0.1000000000000000055511151231257827} | cambiati | → {"p":0.1} |
Guarda la prima riga. Un oggetto piatto con un intero e una stringa ASCII breve fa il round-trip byte per byte, quindi un verificatore che analizza e poi ri-serializza passa tutti i test che hai scritto su una fixture del genere. Poi vai in produzione, e il primo payload che porta un importo monetario di 1.0, un ID oltre 2^53 o il nome di un cliente con un accento fallisce. Non tutti. Solo quelli.
È questo il meccanismo dietro “in locale funziona, in produzione 401 intermittenti”, e fa molto più danno di un verificatore che fallisce sempre. Un verificatore che fallisce sempre si sistema in un’ora. Uno che fallisce sul 3% degli eventi viene addossato al provider, ritentato, escalato, e alla fine ci si convive per settimane. Se il tuo tasso di fallimento sta strettamente tra zero e cento per cento, questa tabella è il primo posto da guardare.
L’ordine delle chiavi è la causa che tutti si aspettano ed è la meno probabile in pratica, perché JSON.parse preserva l’ordine di inserimento per le chiavi stringa. I veri colpevoli sono i numeri e gli spazi.
Il corpo grezzo, framework per framework
Express, con il parser specifico della rotta registrato prima del parser JSON globale:
const express = require('express');
const crypto = require('crypto');
const app = express();
// Questa rotta va registrata PRIMA di app.use(express.json()).
// body-parser marca la richiesta come già analizzata, quindi un raw() successivo restituisce {} in silenzio.
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body; // un Buffer, non un oggetto
const digest = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(raw) // calcola l'hash direttamente sul Buffer, senza toString()
.digest('hex');
console.log('bytes:', raw.length, 'digest:', digest);
res.sendStatus(200);
});
app.use(express.json()); // tutte le altre rotte continuano a ricevere JSON analizzato
app.listen(3000);
Se non puoi riordinare i middleware, conserva invece una copia durante il parsing:
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = Buffer.from(buf); },
}));
FastAPI. Starlette mette il corpo in cache, quindi await request.body() restituisce i byte originali anche in un handler che riceve pure un modello analizzato:
import hashlib, hmac, os
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/webhooks/github")
async def github(request: Request):
raw = await request.body() # byte, esattamente come sono arrivati
expected = "sha256=" + hmac.new(
os.environ["WEBHOOK_SECRET"].encode("utf-8"), raw, hashlib.sha256
).hexdigest()
received = request.headers.get("X-Hub-Signature-256", "")
if not hmac.compare_digest(expected, received):
raise HTTPException(status_code=401, detail="bad signature")
return {"ok": True}
Rails, dove request.raw_post ti dà il corpo non analizzato come stringa:
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def shopify
raw = request.raw_post
digest = Base64.strict_encode64(
OpenSSL::HMAC.digest('sha256', ENV['SHOPIFY_CLIENT_SECRET'], raw)
)
unless OpenSSL.secure_compare(digest, request.headers['X-Shopify-Hmac-SHA256'].to_s)
return head :unauthorized
end
head :ok
end
end
Go, dove il corpo lo leggi tu e devi ricordarti che dopo è svuotato:
func handler(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET")))
mac.Write(raw)
expected := mac.Sum(nil)
got, err := hex.DecodeString(
strings.TrimPrefix(r.Header.Get("X-Hub-Signature-256"), "sha256="))
if err != nil || !hmac.Equal(expected, got) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// Deserializza da raw, mai da r.Body: non gli restano byte.
w.WriteHeader(http.StatusOK)
}
Spring, dove chiedere byte[] salta completamente Jackson:
@PostMapping(path = "/webhooks/github", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> github(@RequestBody byte[] payload,
@RequestHeader("X-Hub-Signature-256") String header)
throws GeneralSecurityException {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(payload));
boolean ok = MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
header.getBytes(StandardCharsets.UTF_8));
return ok ? ResponseEntity.ok().build()
: ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
ContentCachingRequestWrapper è l’alternativa quando il controllo deve farlo un filtro e non puoi cambiare la firma del controller. Ha una trappola sua: getContentAsByteArray() restituisce byte solo dopo che qualcosa a valle ha letto lo stream, quindi chiamarlo prima di chain.doFilter(...) ti dà un array vuoto.
4. Codifiche che non coincidono: hex, base64 e la chiave stessa
Tra il tuo digest e il valore dell’header ci sono tre decisioni di codifica distinte, e ognuna può rompere il confronto da sola.
La codifica del digest. L’output di HMAC-SHA256 è 32 byte. Scritto in hex minuscolo sono 64 caratteri; scritto in base64 standard sono 44 compreso il padding =. Le due righe della Sezione 2 mostrano esattamente questo:
| Codifica | Caratteri | Gli stessi 32 byte scritti come |
|---|---|---|
| hex | 64 | 09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b |
| base64 | 44 | Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs= |
Un’euristica rapida quando hai davanti un header che non conosci: se il valore è 64 caratteri in 0-9a-f, è hex. Se è 44 caratteri che finiscono con =, oppure contiene +, / o lettere maiuscole, è base64. Quando vuoi la conferma invece di tirare a indovinare, passa il valore base64 nel decodificatore Base64 e controlla che restituisca 32 byte; se è così, le due stringhe descrivono lo stesso digest e stavi confrontando formati di testo, non firme.
Il prefisso del valore. GitHub manda sha256= davanti all’hex. Slack manda v0=. Stripe avvolge tutto in una lista di coppie key=value separate da virgole. Nessuno di quei caratteri fa parte del digest, quindi o togli il prefisso dall’header o aggiungilo al tuo valore. Non fare né una cosa né l’altra è la ragione più comune per cui un’implementazione corretta segnala un hmac signature mismatch, e in Node non segnala nemmeno una discrepanza, come spiega la Sezione 7.
La codifica della chiave. Anche il segreto è byte, e la stessa stringa letta come UTF-8, hex o base64 dà tre chiavi diverse. I provider che ti consegnano un token testuale come whsec_... vogliono UTF-8, ma un sacco di sistemi interni distribuiscono segreti base64 o hex che vanno decodificati prima di firmare. Questa modalità di fallimento ha esattamente la stessa forma della versione JWT del problema, ed è trattata a fondo in JWT invalid signature: tutte le cause e come risolverle, incluso come capire se un dato segreto è base64 o testo semplice.
5. Timestamp, tolleranza e finestre di replay
Puoi calcolare un digest che coincide perfettamente e venire respinto comunque. I provider che includono un timestamp si aspettano che tu lo controlli, e un timestamp scaduto è una firma valida che devi rifiutare in ogni caso.
| Provider | Dove sta il timestamp | Finestra |
|---|---|---|
| Stripe | t= dentro Stripe-Signature | 5 minuti (300 secondi) |
| Slack | header X-Slack-Request-Timestamp | 5 minuti |
| GitHub | non inviato | non applicabile |
| Shopify | non inviato | non applicabile |
Sbagliare la finestra fa male in entrambe le direzioni. Troppo generosa, e una richiesta catturata resta riproducibile per tutto il tempo che le concedi, il che vanifica quasi tutto il senso di controllare il timestamp. Troppo stretta, e la normale deriva dell’orologio inizia a respingere consegne reali. Cinque minuti è quello che hanno scelto entrambi i provider, e copiarli è un default sensato.
Prima di allargare una tolleranza, controlla l’orologio. Le immagini dei container non fanno girare NTP, e una VM ripresa da uno snapshot può essere minuti indietro rispetto all’ora reale senza niente nei log che lo dica. Un host che deriva costantemente produce fallimenti che iniziano occasionali e diventano totali, cosa che si legge come una regressione del codice e non lo è.
L’altro bug dell’orologio è l’unità di misura sbagliata. Tutti i provider della tabella mandano secondi epoch. Confrontane uno con un valore in millisecondi come il Date.now() di JavaScript e la differenza è circa mille volte l’età reale, quindi ogni evento cade fuori da qualunque finestra plausibile. Il sintomo è un controllo di tolleranza che respinge il cento per cento delle consegne mentre il digest coincide. Se non sei sicuro di quale unità hai in mano, la lunghezza te lo dice, e secondi epoch contro millisecondi copre le conversioni e le trappole di fuso orario che ci girano intorno.
Usa la stringa grezza del timestamp presa dall’header quando costruisci la stringa da firmare, non un numero analizzato e riformattato. Analizzare 1700000000 come float e stamparlo di nuovo può dare 1700000000.0, e quella è una sequenza di byte diversa.
6. Segreto sbagliato, e segreti che ruotano
Prima di scavare ancora nelle codifiche, escludi la causa più banale: il segreto può non essere quello giusto. La documentazione di Stripe lo dice senza giri di parole, cioè che Stripe genera una chiave segreta distinta per ogni endpoint, e che se punti lo stesso URL sia sulle chiavi di test sia su quelle live il segreto è diverso per ognuna. Da qui nascono tre versioni dello stesso errore.
La modalità test e quella live hanno segreti separati, quindi un valore copiato mentre la dashboard era in test fallisce su ogni consegna live. Poi ogni endpoint ha il suo, e la documentazione aggiunge che con più endpoint devi procurarti un segreto per ciascuno di quelli su cui vuoi verificare le firme: punta due endpoint sullo stesso handler con un solo segreto nell’ambiente e metà del traffico fallisce. E stripe listen stampa un signing secret per l’inoltro locale della CLI, che è un endpoint distinto da qualsiasi cosa registrata nella dashboard, quindi i due non sono interscambiabili.
Da fuori nessuno dei tre sembra un problema di codifica. Il digest è ben formato, il confronto è corretto e il valore nel tuo ambiente è un vero segreto Stripe. Solo che non è quello che ha firmato questa consegna.
La rotazione è la stessa dimensione che si muove sotto i tuoi piedi. È il fallimento che meno sembra un problema di codifica, e quello che più spesso viene preso per un bug nel codice. Nel tuo codice non è cambiato niente, ieri la verifica funzionava, e ora una frazione degli eventi fallisce.
La finestra di sovrapposizione è voluta. Stripe tiene valido il vecchio segreto dell’endpoint fino a 24 ore dopo la rotazione, e in quel periodo l’header Stripe-Signature porta una firma v1 per ogni segreto attivo. Shopify va nella direzione opposta: dopo la rotazione può passare fino a un’ora prima che inizi a usare il nuovo segreto per calcolare i digest, quindi nel frattempo quello che ti serve è il vecchio.
È il comportamento di Stripe a rompere il codice, perché l’header sembra contenere una sola firma. Fare split su , e prendere il primo v1 che trovi funziona benissimo finché non ce ne sono due, momento in cui coincidi circa la metà delle volte, a seconda di quale segreto ha firmato quale evento. Itera su tutte:
const crypto = require('crypto');
function verifyStripe(header, rawBody, secret, toleranceSec = 300) {
let t = null;
const v1 = [];
for (const pair of header.split(',')) {
const idx = pair.indexOf('=');
const key = pair.slice(0, idx);
const value = pair.slice(idx + 1);
if (key === 'v1') v1.push(value);
else if (key === 't') t = value; // conserva la stringa originale
}
if (t === null || v1.length === 0) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (!Number.isFinite(age) || age > toleranceSec) return false;
const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest();
return v1.some((sig) => {
const received = Buffer.from(sig, 'hex');
return received.length === expected.length &&
crypto.timingSafeEqual(received, expected);
});
}
Due dettagli lì dentro contano oltre al ciclo. Il timestamp entra nel payload firmato come la stringa con cui è arrivato, e il corpo viene concatenato come byte invece che attraverso l’interpolazione di template, che lo decodificherebbe prima come UTF-8.
La stessa forma vale quando la rotazione la fai tu: accetta sia il vecchio sia il nuovo segreto per tutta la durata della sovrapposizione, poi butta via il vecchio. Qualunque cosa tu scelga come nuovo segreto ha bisogno di entropia piena, quindi generala invece di digitarla, usando qualcosa come il generatore di segreti di firma per un valore casuale da 256 bit.
7. Confronto delle firme senza fughe di timing
Una volta che hai due digest, come li confronti è una decisione di sicurezza. L’uguaglianza tra stringhe ritorna appena trova un byte diverso, quindi il tempo che impiega rivela quanti byte iniziali erano corretti. Un attaccante che può inviare molte richieste lo usa per ricostruire una firma valida un byte alla volta. È lento e rumoroso su internet, e del tutto praticabile su una rete locale.
Ogni runtime include un confronto a tempo costante:
| Linguaggio | Confronto a tempo costante | Quando le lunghezze differiscono |
|---|---|---|
| Node | crypto.timingSafeEqual(a, b) | solleva un’eccezione |
| Python | hmac.compare_digest(a, b) | restituisce False |
| Go | hmac.Equal(a, b) | restituisce false |
| PHP | hash_equals($known, $user) | restituisce false |
| Ruby | OpenSSL.secure_compare(a, b) | restituisce false |
Quell’ultima colonna è la fonte di un’intera classe di incidenti confusi. Node è l’eccezione, e non fallisce con garbo:
RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length
Ragiona su quando scatta. Un digest SHA-256 in hex è 64 caratteri. Il valore in X-Hub-Signature-256 è 71, perché sha256= sono sette caratteri. Dimenticati di togliere il prefisso e i due buffer hanno lunghezze diverse, quindi timingSafeEqual solleva un’eccezione invece di restituire false. Non catturata, quell’eccezione risale fuori dal tuo handler ed Express la trasforma in un 500.
Ora considera cosa vedi. Stai cercando una risposta webhook 401 unauthorized e ottieni un errore del server, quindi vai a leggere il tuo handler, la tua chiamata al database, il tuo dispatcher di eventi. Il bug vero è una riga sopra il confronto. Confrontare un digest hex di 64 caratteri con uno base64 di 44 solleva un’eccezione per lo stesso motivo, il che vuol dire che in Node anche una codifica sbagliata emerge come un 500 invece che come un rifiuto pulito.
La soluzione è controllare la lunghezza tu e restituire false:
function safeEqualHex(receivedHex, expectedHex) {
const a = Buffer.from(receivedHex, 'hex');
const b = Buffer.from(expectedHex, 'hex');
if (a.length !== b.length) return false; // guardia prima della chiamata
return crypto.timingSafeEqual(a, b);
}
Far filtrare la lunghezza è innocuo: la lunghezza di un digest è fissata dall’algoritmo ed è pubblica. Quello che non devi far filtrare è quale prefisso ha coinciso. La scheda Verifica del generatore HMAC fa confluire la differenza di lunghezza nello stesso accumulatore a tempo costante invece di uscire subito, così una lunghezza diversa torna come un semplice false e non come un’eccezione, e puoi controllare un valore di header contro il digest che hai calcolato senza scrivere codice usa e getta.
8. Quando il livello di trasporto ha cambiato i tuoi byte
Hai escluso la stringa firmata, il corpo grezzo, le codifiche, l’orologio e la rotazione. Resta la possibilità che i byte che arrivano al tuo processo non siano i byte che hanno lasciato il provider.
Compressione. Un provider o un proxy può mandare il corpo compresso con gzip e Content-Encoding: gzip. La firma copre il payload non compresso, quindi devi calcolare l’hash dopo la decompressione. Alcuni framework decomprimono in modo trasparente e altri ti consegnano i byte compressi, e un corpo che nel log sembra spazzatura binaria è l’indizio.
Trasferimento chunked. Con Transfer-Encoding: chunked non c’è Content-Length, e il codice che si fida di quell’header per dimensionare un buffer di lettura tronca il corpo. Il digest di un corpo troncato è un’assurdità valida: non coinciderà mai, e niente sembra fuori posto.
Proxy e WAF. Qualunque livello che legge e riscrive il corpo può cambiarlo. AWS API Gateway può codificare il corpo in base64 prima che arrivi a una Lambda, quindi devi decodificare prima di calcolare l’hash. Anche load balancer applicativi, service mesh e web application firewall a volte normalizzano o ricodificano i payload. Verifica confrontando la lunghezza in byte che vede il tuo handler con il Content-Length che ha mandato il provider.
Codifica dei caratteri e BOM. I payload possono contenere caratteri non ASCII, e la documentazione di GitHub è esplicita: il payload va gestito come UTF-8. Decodificare il corpo in una stringa con il charset sbagliato e ricodificarlo distrugge ogni carattere multi-byte. Un byte order mark UTF-8, EF BB BF, messo davanti da un editor o da un serializzatore ben intenzionato aggiunge tre byte che nessuno ha firmato.
Fine riga e spazi di troppo. Un corpo che ha attraversato un confine di file in modalità testo può arrivare con LF riscritto in CRLF. Leggi anche la specifica del provider per la stringa di firma esatta: qualcuno aggiunge un carattere proprio, e Typeform è un caso documentato in cui un a capo finale fa parte di ciò di cui si calcola l’hash. Quando la documentazione di un provider menziona un carattere in più, prendila alla lettera.
9. Un workflow di debug ripetibile
Eseguili in ordine. Ogni passo o trova il bug o elimina un ramo, e fermarsi presto è proprio il punto.
- Registra i byte grezzi prima che giri qualunque middleware. Scrivi il corpo su un file, oppure registra la sua lunghezza in byte più il suo SHA-256, dal punto più precoce del ciclo di vita della richiesta a cui riesci ad arrivare. La sola lunghezza risolve un numero sorprendente di casi: un valore maggiore di uno rispetto all’atteso è un a capo finale, maggiore di tre è un BOM.
- Calcola il digest a mano. Incolla quei byte esatti e il tuo segreto nel generatore HMAC, scegli SHA-256 e imposta il formato di output in modo che corrisponda all’header. Questo passo divide il problema in due metà nette, ed è per questo che conviene farlo presto.
- Confronta il valore calcolato a mano con l’header. Uguali vuol dire che i byte e il segreto sono entrambi corretti e il bug sta da qualche parte nel tuo percorso di codice, quindi vai a leggere il tuo confronto. Diversi vuol dire che uno degli input è sbagliato, quindi continua.
- Controlla la stringa firmata contro la tabella della Sezione 2. Questo provider mette davanti un timestamp? Con quale separatore? Aggiungi il prefisso nello strumento e ricalcola.
- Cambia la codifica del digest. Ricalcola in hex e in base64 e confronta entrambi con l’header. Un valore di header di 44 caratteri con un
=alla fine è base64, qualunque cosa avesse assunto il tuo codice. - Cambia la codifica della chiave. Prova il segreto come testo, poi hex, poi base64. Una delle tre di solito produce una coincidenza, e quella ti dice cosa si aspetta il provider.
- Controlla l’orologio e lo stato della rotazione. Confronta l’ora del tuo server con una fonte affidabile, verifica di gestire secondi epoch e controlla nella dashboard del provider se c’è stata una rotazione nelle ultime 24 ore.
Due abitudini rendono questo ciclo molto più rapido. Primo, cattura un payload che fallisce e lavoraci offline invece di aspettare la consegna successiva. Secondo, rimanda quel corpo catturato al tuo endpoint con una firma fissa, così l’input non varia mai tra un tentativo e l’altro. Il generatore di comandi cURL assembla la richiesta con gli header esatti e un corpo letto da un file, il che tiene stabili i byte tra le varie esecuzioni. Riprodurre il fallimento a comando è ciò che trasforma una segnalazione intermittente di webhook signature verification failed in una correzione da cinque minuti.
Se ti serve ancora aprire un ticket di supporto, includi la lunghezza in byte del corpo di cui hai calcolato l’hash, il valore dell’header alla lettera, la costruzione della stringa firmata che hai usato e la codifica del digest. Non includere mai il segreto stesso.
FAQ
Perché la firma del mio webhook funziona in locale e fallisce in produzione?
Il tuo payload di test probabilmente sopravvive invariato a un round-trip JSON, quindi ri-serializzarlo è innocuo. I payload reali contengono float, interi grandi, escape Unicode o spazi in più, e quelli i byte li cambiano. Firma il corpo grezzo invece di una copia ri-serializzata; la tabella della Sezione 3 mostra quali forme si rompono.
Devo includere il prefisso sha256= quando confronto le firme?
Toglilo, oppure aggiungilo al tuo valore così che le due stringhe coincidano esattamente. Il digest hex che calcoli è 64 caratteri e il valore dell’header è 71 con il prefisso. Alcune funzioni di confronto restituiscono false quando le lunghezze non coincidono, e il timingSafeEqual di Node solleva un’eccezione invece di restituire false.
Posso verificare la firma dopo che il framework ha analizzato il JSON?
Non in modo affidabile. La ri-serializzazione riproduce i byte originali solo per payload senza float, senza interi oltre 2^53, senza escape Unicode e senza spazi in più. Nel momento in cui ne compare uno il digest cambia, quindi la verifica passa in test e fallisce su una frazione degli eventi in produzione.
Perché Stripe e GitHub producono firme diverse per lo stesso payload?
Perché calcolano l’hash di stringhe diverse. GitHub firma il corpo grezzo da solo. Stripe firma il timestamp, un . letterale, poi il corpo, quindi lo stesso payload consegnato in due momenti diversi dà due digest diversi. Slack mette davanti v0: e il proprio timestamp. Stesso algoritmo, input diverso.
Quanto deve essere ampia la tolleranza sul timestamp?
Cinque minuti è quello che usano Stripe e Slack, e copiarlo è un default ragionevole. Finestre più corte respingono consegne legittime appena l’orologio del tuo server deriva. Finestre più lunghe allargano il periodo in cui una richiesta catturata può essere riproposta. Sincronizza gli orologi con NTP prima di allentare la tolleranza.
timingSafeEqual restituisce false quando le lunghezze differiscono?
No. Node solleva RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length. Non catturata, diventa un 500 invece di un 401, il che ti spedisce a cercare il bug nel tuo handler invece che nella riga sopra il confronto. Confronta prima le lunghezze e restituisci false tu.
Il mio provider ha ruotato il segreto, allora perché alcuni webhook continuano a fallire?
Le finestre di rotazione si sovrappongono. Stripe tiene valido il vecchio segreto fino a 24 ore e manda una firma v1 per ogni segreto attivo, quindi il codice che legge solo il primo v1 fallisce su circa metà degli eventi. Shopify può metterci fino a un’ora prima di iniziare a usare il nuovo segreto.
Conclusione
La verifica è un confronto di byte, quindi webhook signature verification failed si risolve sempre in un disaccordo sui byte, non in qualcosa di crittografico. Tieni separate le tre dimensioni mentre fai il debug:
- Quali byte sono stati firmati. Cattura il corpo grezzo prima che un parser lo tocchi. Non calcolare mai l’hash di un oggetto ri-serializzato, perché coincide abbastanza spesso da passare i tuoi test e non abbastanza spesso da funzionare.
- Quali byte di chiave sono stati usati. Le letture come testo, hex e base64 di un solo segreto danno tre chiavi diverse.
- In quale codifica hai confrontato. L’hex è 64 caratteri, il base64 è 44, ed entrambi descrivono gli stessi 32 byte.
- Tutto il resto. Il prefisso del timestamp, il prefisso del valore, la finestra di tolleranza, la sovrapposizione della rotazione e il livello di trasporto, più o meno in quest’ordine di probabilità.
- Come hai confrontato. Metti una guardia sulla lunghezza, poi usa la funzione a tempo costante del tuo runtime.
Quando ti serve un valore di cui fidarti per il confronto, calcolalo fuori dalla tua applicazione: incolla il corpo e il segreto nel generatore HMAC e lascia che sia lui a dirti quale lato è sbagliato.