Vai al contenuto

Basi della sicurezza

"No signatures found matching the expected signature" su Supabase

Un webhook Stripe in una Edge Function di Supabase dice "No signatures found matching the expected signature"? Due cause. Non smettere di verificare.

Vlad Tkachenko10 min di lettura
Una busta chiusa con un sigillo di ceralacca turchese e, accanto, un timbro il cui disegno non combacia con l'impronta, segnato in giallo.

In breve

  • "No signatures found matching the expected signature for payload" vuol dire che la tua funzione ha controllato la firma di Stripe e non corrispondeva. O il corpo è stato cambiato prima del controllo, o il signing secret non è quello con cui Stripe ha firmato.
  • Leggi il corpo con await req.text() e passa quella stringa, intatta, a constructEventAsync. Poi confronta il secret whsec_ della tua funzione con quello dell'endpoint esatto da cui Stripe sta inviando.
  • Non far sparire l'errore saltando il controllo. Il webhook deve girare con verify_jwt = false, quindi la firma è l'unica cosa che impedisce a un estraneo di dire alla tua app che un pagamento è andato a buon fine.

Hai collegato Stripe alla tua app, un pagamento di prova è andato a buon fine, e il webhook che doveva segnare l'ordine come pagato non ha fatto niente. La dashboard di Stripe mostra la consegna fallita, e i log della tua Edge Function di Supabase dicono questo:

No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?

La buona notizia sta nell'origine dell'errore. Lo lancia la parte della tua funzione che controlla che la richiesta arrivi davvero da Stripe, il che vuol dire che quel controllo esiste e sta facendo il suo lavoro. "No signatures found matching the expected signature for payload" ha due cause, e nessuna si risolve controllando di meno. La soluzione che compare per prima quando la si cerca, avvolgere il controllo in modo che l'errore venga ignorato, è quella che permette a chiunque di segnare un ordine come pagato.

Che cosa vuol dire "No signatures found matching the expected signature"?

La tua funzione ha controllato la firma di Stripe sulla richiesta, e non corrispondeva.

Immagina ogni webhook di Stripe come una lettera chiusa con un sigillo di ceralacca. Stripe preme il sigillo con un timbro che avete solo Stripe e tu: il signing secret del tuo endpoint, un valore che inizia con whsec_. Quando la lettera arriva, la tua funzione preme la sua copia del timbro contro la cera. Se i disegni combaciano, la lettera è di Stripe e nessuno l'ha toccata lungo la strada.

Il sigillo copre la lettera esattamente com'è stata scritta. La libreria di Stripe prende il testo della richiesta, l'ora dall'header Stripe-Signature e il tuo secret, calcola come dovrebbe essere la firma e la cerca in quell'header. Il messaggio qui sopra è scritto nello stesso stripe-node, ed è quello che lancia quando lì non corrisponde niente. La pagina di Stripe sugli errori di firma lo dice chiaramente: almeno uno dei tre valori che hai passato è sbagliato.

Quindi i posti dove guardare sono due. O la lettera è stata cambiata prima che la tua funzione controllasse il sigillo, o la tua funzione ha in mano il timbro sbagliato.

Causa 1: il corpo è stato cambiato prima del controllo

In una Edge Function la versione più comune è un corpo letto prima come JSON e poi ritrasformato in testo per il controllo.

const event = await req.json()
await stripe.webhooks.constructEventAsync(JSON.stringify(event), signature, secret, undefined, cryptoProvider)

Si legge in modo naturale e fallisce ogni volta. req.json() tiene i dati e butta il testo: gli spazi, gli a capo, l'ordine in cui Stripe ha scritto le cose. JSON.stringify scrive poi un testo nuovo con gli stessi dati dentro. Il sigillo è stato premuto sul testo di Stripe, e questa è la copia di qualcun altro con un'altra calligrafia, quindi non può mai combaciare.

L'esempio di webhook Stripe di Supabase fa diversamente: legge il corpo una volta sola, come testo, e passa quella stringa senza toccarla.

const signature = req.headers.get('Stripe-Signature')
const body = await req.text()
const event = await stripe.webhooks.constructEventAsync(
  body,
  signature,
  Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET'),
  undefined,
  cryptoProvider,
)

Il commento in quell'esempio spiega perché: la verifica si basa sul corpo grezzo della richiesta e non sul JSON già interpretato. Non devi interpretare niente tu dopo, perché constructEventAsync restituisce l'evento già interpretato appena il sigillo torna.

La firma copre il testo che Stripe ha inviato, non i dati che contiene. Un testo riscritto con dati identici non passa il controllo.

Lo stesso succede fuori dal tuo codice. La pagina di Stripe nomina framework che interpretano o riformattano un corpo prima che il tuo codice lo veda, e il messaggio di stripe-node aggiunge che uno strumento che ti inoltra i webhook deve conservare il corpo esatto, compresi la formattazione del JSON e il tipo di a capo. Se tra Stripe e la tua funzione c'è un proxy o un servizio di inoltro, controlla che passi il corpo così come è arrivato.

Causa 2: il signing secret è quello sbagliato

Se il corpo è intatto, è sbagliato il timbro, e la pagina di Stripe dice che è la causa più comune di tutte.

Tre valori diversi possono finire al posto del signing secret, e si somigliano:

  • Il secret di un altro endpoint. La guida ai webhook di Stripe dice che ogni endpoint riceve il suo secret e che lo stesso indirizzo configurato in modalità test e live ha un secret diverso in ciascuna. Un pagamento live controllato con il secret dell'endpoint di test fallisce, e lo stesso vale al contrario.
  • Il secret stampato da stripe listen. Quando inoltri gli eventi alla tua macchina con lo strumento a riga di comando di Stripe, stampa un secret whsec_ suo. Stripe dice che è diverso dal secret dell'endpoint della dashboard e che i due vengono confusi. Funziona per gli eventi che lo strumento inoltra, e per nient'altro.
  • La tua chiave API di Stripe. sk_live_… o sk_test_… è la chiave con cui la tua funzione chiama Stripe. Non è un signing secret, e un controllo fatto con quella non può mai combaciare.

Per trovare quello giusto, apri l'endpoint nella dashboard di Stripe sotto Workbench, poi Webhooks, e clicca su Reveal secret. Prima controlla in che modalità sei. Poi confrontalo con quello che legge la tua funzione. Nell'esempio qui sopra è STRIPE_WEBHOOK_SIGNING_SECRET, impostato nella pagina Edge Function Secrets del tuo progetto Supabase o con supabase secrets set.

Già che ci sei, guarda un'ultima cosa. Se il valore è stato incollato con uno spazio o un a capo alla fine, stripe-node aggiunge una nota allo stesso errore: "The provided signing secret contains whitespace." Cancella il valore e incollalo di nuovo senza niente dopo.

Ogni endpoint, ogni modalità e lo strumento a riga di comando firmano con un secret proprio. Solo quello che ha firmato questo evento può controllarlo.

I messaggi che si somigliano e vogliono dire altro

stripe-node lancia diversi errori dallo stesso controllo, e ognuno indica un problema diverso. Sono tutti scritti nel suo codice dei webhook:

Che cosa dicono i tuoi logChe cosa è successo
No signatures found matching the expected signature for payloadIl corpo è stato cambiato, o il secret è quello sbagliato. Questo articolo.
Webhook payload must be provided as a string or a BufferHai passato l'oggetto interpretato di req.json(). Passa il testo di req.text().
No stripe-signature header value was provided.La richiesta non aveva l'header Stripe-Signature. Forse non arrivava affatto da Stripe.
Timestamp outside the tolerance zoneIl sigillo combaciava, ma l'evento è più vecchio del limite. Le librerie di Stripe usano 5 minuti.
SubtleCryptoProvider cannot be used in a synchronous context.Hai chiamato constructEvent. Con il provider Web Crypto che serve a Deno, usa constructEventAsync.

E uno che il tuo codice non lo raggiunge mai. Se la dashboard di Stripe mostra la consegna fallita con un 401 e i log della tua funzione sono vuoti, la piattaforma di Supabase ha rifiutato la richiesta prima che la tua funzione partisse, per un controllo che in un webhook deve essere spento.

Perché il tuo webhook ha bisogno di verify_jwt disattivato

Perché Stripe non può dimostrare chi è come fanno gli utenti della tua app, e con il controllo attivo ogni consegna viene rifiutata.

Supabase mette davanti a ogni Edge Function un controllo chiamato verify_jwt, attivo per impostazione predefinita. Vuole un token Supabase nell'header Authorization della richiesta, e Stripe non ne ha nessuno da mandare. La guida alla configurazione delle funzioni di Supabase usa proprio questo come esempio, dicendo che i webhook di Stripe devono essere raggiungibili pubblicamente, e spegne il controllo per quella sola funzione in supabase/config.toml:

[functions.stripe-webhook]
verify_jwt = false

Il comando supabase functions deploy accetta --no-verify-jwt per la stessa cosa. L'esempio Stripe di Supabase porta la stessa istruzione in un commento: Stripe verifica la richiesta tramite la sua firma, quindi fai il deploy con verify_jwt = false.

Con il controllo della piattaforma spento, ogni richiesta da internet raggiunge il tuo codice, e il controllo del sigillo dentro la tua funzione è l'unica cosa che distingue le lettere di Stripe da quelle di chiunque altro.

Perché non dovresti mai saltare il controllo della firma

Perché senza, chiunque può dire alla tua app che un pagamento è andato a buon fine.

L'indirizzo di una funzione di webhook è l'URL del tuo progetto, che chiunque può leggere nel tuo frontend, seguito da /functions/v1/ e dal nome della funzione, di solito quello ovvio. L'esempio della guida alla configurazione la chiama proprio stripe-webhook. Con verify_jwt spento, la piattaforma lascia passare ogni richiesta fino al tuo codice. Se il tuo codice non controlla più il sigillo, crede a ogni lettera che riceve.

Quindi un estraneo manda alla tua funzione una richiesta che dice checkout.session.completed, con dentro un ordine o un cliente scelto da lui. La tua funzione fa quello che fa con una vera: segna l'ordine come pagato, fa salire di piano l'account, sblocca il download. Nessuno ha pagato. Stripe non ne ha traccia, perché Stripe non l'ha mai inviata.

Tre modi di saltare il controllo finiscono tutti lì:

  • Cancellarlo perché non passava.
  • Intercettare l'errore e andare avanti, registrando il fallimento ed elaborando comunque l'evento. L'esempio restituisce un 400 dal suo blocco catch, ed è la forma da tenere: una richiesta che non passa il controllo si ferma lì.
  • Mettere la tolleranza di tempo a 0. La guida ai webhook di Stripe dice di non farlo: 0 spegne del tutto il controllo dell'età, così un evento vero che qualcuno ha catturato può essere rimandato più tardi.

Una chiave segreta Stripe attiva nel tuo frontend è l'altro modo in cui i pagamenti vanno storti, ed è quello che possiamo vedere da fuori: ne abbiamo trovata una in 3 app su 30.998 scansionate. La nostra scansione gratuita legge il JavaScript della tua app pubblicata come farebbe un estraneo e nomina ogni chiave che trova, dicendo se è una di quelle che devono stare lì. Ci vogliono circa 20 secondi e non serve un account: scansiona la tua app.

Se un pagamento falso dovesse passare

Un controllo tolto da un prompt non si annuncia. Un builder a cui chiedi di far tornare a funzionare il webhook è libero di prendere la scorciatoia qui sopra, e la tua app continua a funzionare nel frattempo.

Reeve Monitor tiene d'occhio quello che un estraneo può vedere da fuori:

  • tutti e nove i controlli ogni ora, su un massimo di tre app
  • una email il giorno in cui una nuova scansione valuta la tua app peggio della precedente; una chiave segreta nel tuo JavaScript limita da sola il voto a D
  • se l'app risponde, ogni 60 secondi
  • un rapporto mensile di quello che ha visto

Un evento falso scrive nel tuo database come uno vero. Reeve Care tiene una copia del tuo database Supabase di prima che succedesse.

  • una copia cifrata secondo una pianificazione, conservata sullo storage di Reeve nell'UE, fuori dal tuo account Supabase
  • ogni copia verificata prima di contare, contando le righe di ogni tabella
  • un ripristino in un clic che salva quello che c'è adesso prima di sostituirlo
  • anche i tuoi file caricati, appena colleghi una credenziale di Storage
  • tutto quello che fa Monitor

Che cosa fare adesso

Cosa fare

  • Apri la consegna fallita nella dashboard di Stripe e la scheda Logs della tua funzione, e leggi il messaggio esatto prima di cambiare qualsiasi cosa.
  • Leggi il corpo con await req.text() e passa quella stringa intatta a constructEventAsync. Non controllare mai un evento passato da JSON.stringify.
  • Copia il secret whsec_ dell'endpoint da cui Stripe invia, nella modalità giusta, e incollalo in Edge Function Secrets senza niente dopo.
  • Tieni verify_jwt = false sul webhook, e il controllo della firma come prima cosa che fa la funzione.
  • Restituisci un 400 quando il controllo fallisce. Non intercettare mai l'errore per elaborare comunque l'evento.

Il resto di quello che serve a una funzione che custodisce un secret è in spostare un secret in una Edge Function, e se il tuo errore è quello generico di supabase-js, leggere un codice di stato non-2xx spiega dov'è il messaggio vero.

FAQ

Che cosa vuol dire "No signatures found matching the expected signature for payload"?

La tua funzione di webhook ha calcolato la firma che si aspettava dal corpo della richiesta e dal tuo signing secret, e nessuna delle firme nell'header Stripe-Signature corrispondeva. La libreria di Stripe lancia questo errore quando il corpo che riceve non è, byte per byte, quello che Stripe ha inviato, o quando il secret non è quello dell'endpoint che ha inviato l'evento.

Perché fallisce se passo JSON.stringify dell'evento?

Perché la firma copre il testo esatto che Stripe ha inviato, spazi e a capo compresi. Leggere il corpo con req.json() e ritrasformarlo in testo con JSON.stringify ti dà gli stessi dati in un altro testo, e un altro testo ha un'altra firma. Leggi il corpo una volta con await req.text() e passa quella stringa a constructEventAsync.

Quale signing secret uso?

Il secret whsec_ dell'endpoint esatto a cui Stripe consegna. Ogni endpoint ha il suo, lo stesso indirizzo configurato in modalità test e live ha un secret diverso in ciascuna, e il comando stripe listen stampa un secret suo che vale solo per gli eventi che inoltra. La tua chiave API sk_ non è affatto un signing secret.

La mia Edge Function per il webhook Stripe deve avere verify_jwt disattivato?

Sì. Stripe non ha un token Supabase da inviare, quindi con il controllo attivo ogni consegna viene rifiutata con un 401 prima che il tuo codice parta. L'esempio Stripe di Supabase dice di fare il deploy con verify_jwt = false. Così il controllo della firma dentro la tua funzione diventa l'unico controllo che c'è, e non va mai saltato né intercettato e ignorato.

Posso togliere il controllo della firma per far funzionare i pagamenti?

Puoi, e a quel punto chiunque trovi l'indirizzo della funzione può mandarle un evento che dice che un pagamento è andato a buon fine. L'indirizzo è l'URL del tuo progetto, che sta nel tuo frontend, più il nome della funzione. Senza il controllo, la tua funzione crede a ogni richiesta che riceve e concede quello che l'evento dice sia stato pagato.

Scritto da

Vlad Tkachenko

Fondatore di Reeve

Passo le giornate a guardare app costruite con Lovable, Bolt, v0, Cursor e Replit, e la breve lista di errori che vi ricompaiono di continuo.

Altro sull'autore

Da leggere dopo

Tutti gli articoli

Fai il backup del database Supabase prima della prossima modifica

Reeve ne tiene una copia cifrata a intervalli programmati e la ripristina per te se qualcosa va storto.

Vedi i backup Supabase

Controllo esterno automatizzato, non un audit completo. L'assenza di risultati non è una garanzia di sicurezza.