Bases de la sécurité
"No signatures found matching the expected signature" sur Supabase
Un webhook Stripe dans une Edge Function Supabase affiche "No signatures found matching the expected signature" ? Deux causes. Gardez la vérification.

En bref
- "No signatures found matching the expected signature for payload" signifie que votre fonction a vérifié la signature de Stripe et qu'elle ne correspondait pas. Soit le corps a été modifié avant la vérification, soit le signing secret n'est pas celui avec lequel Stripe a signé.
- Lisez le corps avec await req.text() et passez cette chaîne, intacte, à constructEventAsync. Comparez ensuite le secret whsec_ de votre fonction avec celui de l'endpoint exact depuis lequel Stripe envoie.
- Ne faites pas disparaître l'erreur en sautant la vérification. Le webhook doit tourner avec verify_jwt = false, donc la signature est la seule chose qui empêche un inconnu de dire à votre app qu'un paiement a réussi.
Vous avez branché Stripe sur votre app, un paiement de test est passé, et le webhook censé marquer la commande comme payée n'a rien fait. Le tableau de bord de Stripe montre la livraison en échec, et les logs de votre Edge Function Supabase disent ceci :
No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?
La bonne nouvelle tient à l'origine de l'erreur. Elle est levée par la partie de votre fonction qui vérifie que la requête vient bien de Stripe, ce qui veut dire que cette vérification existe et fait son travail. "No signatures found matching the expected signature for payload" a deux causes, et aucune ne se règle en vérifiant moins. Le correctif qui sort en premier quand on cherche, envelopper la vérification pour que l'erreur soit ignorée, est celui qui permet à n'importe qui de marquer une commande comme payée.
Que signifie "No signatures found matching the expected signature" ?
Votre fonction a vérifié la signature de Stripe sur la requête, et elle ne correspondait pas.
Imaginez chaque webhook envoyé par Stripe comme une lettre fermée par un cachet
de cire. Stripe presse le cachet avec un sceau que seuls Stripe et vous
détenez : le signing secret de votre endpoint, une valeur qui commence par
whsec_. Quand la lettre arrive, votre fonction presse sa propre copie du sceau
contre la cire. Si les motifs coïncident, la lettre vient de Stripe et personne
n'y a touché en route.
Le cachet couvre la lettre exactement telle qu'elle a été écrite. La
bibliothèque de Stripe prend le texte de la requête, l'heure de l'en-tête
Stripe-Signature et votre secret, calcule ce que devrait être la signature et
la cherche dans cet en-tête. Le message ci-dessus est écrit
dans stripe-node lui-même,
et c'est ce qu'il lève quand rien ne correspond. La
page de Stripe sur les erreurs de signature
le dit sans détour : au moins une des trois valeurs que vous avez passées est
fausse.
Il y a donc deux endroits où regarder. Soit la lettre a été modifiée avant que votre fonction ne vérifie le cachet, soit votre fonction tient le mauvais sceau.
Cause 1 : le corps a été modifié avant la vérification
Dans une Edge Function, la version la plus courante est un corps lu d'abord comme du JSON, puis retransformé en texte pour la vérification.
const event = await req.json()
await stripe.webhooks.constructEventAsync(JSON.stringify(event), signature, secret, undefined, cryptoProvider)
Ça se lit naturellement et ça échoue à chaque fois. req.json() garde les
données et jette le texte : les espaces, les sauts de ligne, l'ordre dans lequel
Stripe a écrit les choses. JSON.stringify écrit ensuite un nouveau texte avec
les mêmes données dedans. Le cachet a été pressé sur le texte de Stripe, et
ceci est la copie de quelqu'un d'autre, d'une autre écriture, donc ça ne peut
jamais correspondre.
L'exemple de webhook Stripe de Supabase fait autrement : il lit le corps une seule fois, comme du texte, et transmet cette chaîne sans y toucher.
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,
)
Le commentaire de cet exemple dit pourquoi : la vérification repose sur le corps
brut de la requête et non sur le JSON analysé. Vous n'avez rien à analyser
vous-même ensuite, car constructEventAsync renvoie l'événement déjà analysé
dès que le cachet est bon.
La même chose arrive en dehors de votre code. La page de Stripe cite des frameworks qui analysent ou reformatent un corps avant que votre code ne le voie, et le message de stripe-node ajoute qu'un outil qui vous transfère des webhooks doit préserver le corps exact, mise en forme du JSON et type de saut de ligne compris. Si un proxy ou un service de transfert se trouve entre Stripe et votre fonction, vérifiez qu'il transmet le corps tel qu'il est arrivé.
Cause 2 : le signing secret n'est pas le bon
Si le corps est intact, c'est le sceau qui est faux, et la page de Stripe dit que c'est la cause la plus courante de toutes.
Trois valeurs différentes peuvent se retrouver à la place du signing secret, et elles se ressemblent :
- Le secret d'un autre endpoint. Le guide des webhooks de Stripe dit que chaque endpoint reçoit son propre secret, et que la même adresse configurée en mode test et en mode live a un secret différent dans chacun. Un paiement live vérifié avec le secret de l'endpoint de test échoue, et l'inverse aussi.
- Le secret affiché par
stripe listen. Quand vous transférez des événements vers votre machine avec l'outil en ligne de commande de Stripe, il affiche son propre secretwhsec_. Stripe dit qu'il diffère du secret de l'endpoint du tableau de bord et qu'on les confond. Il marche pour les événements que l'outil transfère, et pour rien d'autre. - Votre clé API Stripe.
sk_live_…ousk_test_…est la clé avec laquelle votre fonction appelle Stripe. Ce n'est pas un signing secret, et une vérification faite avec elle ne peut jamais correspondre.
Pour trouver le bon, ouvrez l'endpoint dans le tableau de bord de Stripe sous
Workbench, puis Webhooks, et cliquez sur Reveal secret. Vérifiez
d'abord dans quel mode vous êtes. Comparez-le ensuite avec celui que lit votre
fonction. Dans l'exemple ci-dessus, c'est STRIPE_WEBHOOK_SIGNING_SECRET,
défini sur la page Edge Function Secrets de votre projet Supabase ou avec
supabase secrets set.
Tant que vous y êtes, regardez une dernière chose. Si la valeur a été collée avec un espace ou un saut de ligne à la fin, stripe-node ajoute une note à la même erreur : "The provided signing secret contains whitespace." Effacez la valeur et collez-la de nouveau, sans rien derrière.
Les messages qui se ressemblent et veulent dire autre chose
stripe-node lève plusieurs erreurs depuis la même vérification, et chacune désigne un problème différent. Toutes sont écrites dans son code de webhooks :
| Ce que disent vos logs | Ce qui s'est passé |
|---|---|
No signatures found matching the expected signature for payload | Le corps a été modifié, ou le secret n'est pas le bon. Cet article. |
Webhook payload must be provided as a string or a Buffer | Vous avez passé l'objet analysé de req.json(). Passez le texte de req.text(). |
No stripe-signature header value was provided. | La requête n'avait pas d'en-tête Stripe-Signature. Elle ne venait peut-être pas de Stripe du tout. |
Timestamp outside the tolerance zone | Le cachet correspondait, mais l'événement est plus vieux que la limite. Les bibliothèques de Stripe prennent 5 minutes. |
SubtleCryptoProvider cannot be used in a synchronous context. | Vous avez appelé constructEvent. Avec le fournisseur Web Crypto dont Deno a besoin, utilisez constructEventAsync. |
Et une qui n'atteint jamais votre code. Si le tableau de bord de Stripe montre la livraison en échec avec un 401 et que les logs de votre fonction sont vides, la plateforme de Supabase a refusé la requête avant que votre fonction ne tourne, à cause d'une vérification qui doit être coupée pour un webhook.
Pourquoi votre webhook a besoin de verify_jwt désactivé
Parce que Stripe ne peut pas prouver qui il est comme le font les utilisateurs de votre app, et qu'avec la vérification active, chaque livraison est refusée.
Supabase place devant chaque Edge Function une vérification appelée
verify_jwt, active par défaut. Elle veut un jeton Supabase dans l'en-tête
Authorization de la requête, et Stripe n'en a aucun à envoyer. Le
guide de configuration des fonctions
de Supabase prend justement cet exemple, en disant que les webhooks Stripe
doivent être accessibles publiquement, et coupe la vérification pour cette seule
fonction dans supabase/config.toml :
[functions.stripe-webhook]
verify_jwt = false
La commande supabase functions deploy accepte --no-verify-jwt pour la même
chose. L'exemple Stripe de Supabase porte la même consigne dans un
commentaire : Stripe vérifie la requête par sa signature, donc déployez avec
verify_jwt = false.
Avec la vérification de la plateforme coupée, n'importe quelle requête d'internet atteint votre code, et la vérification du cachet dans votre fonction est la seule chose qui distingue les lettres de Stripe de celles de n'importe qui.
Pourquoi il ne faut jamais sauter la vérification de signature
Parce que sans elle, n'importe qui peut dire à votre app qu'un paiement est passé.
L'adresse d'une fonction de webhook est l'URL de votre projet, lisible par tous
dans votre frontend, suivie de /functions/v1/ et du nom de la fonction,
généralement le plus évident. L'exemple du guide de configuration l'appelle
lui-même stripe-webhook. Avec verify_jwt coupé, la plateforme laisse passer
chaque requête jusqu'à votre code. Si votre code ne vérifie plus le cachet, il
croit chaque lettre qui arrive.
Un inconnu envoie donc à votre fonction une requête qui dit
checkout.session.completed, avec une commande ou un client de son choix
dedans. Votre fonction fait ce qu'elle fait avec une vraie : elle marque la
commande comme payée, fait monter le compte d'offre, débloque le
téléchargement. Personne n'a payé. Stripe n'en a aucune trace, parce que Stripe
ne l'a jamais envoyée.
Trois façons de sauter la vérification finissent toutes là :
- La supprimer parce qu'elle ne passait pas.
- Intercepter l'erreur et continuer, en journalisant l'échec et en traitant l'événement quand même. L'exemple renvoie un 400 depuis son bloc catch, et c'est la forme à garder : une requête qui échoue à la vérification s'arrête là.
- Mettre la tolérance de temps à 0. Le guide des webhooks de Stripe dit de ne pas le faire : 0 coupe entièrement le contrôle d'ancienneté, si bien qu'un vrai événement que quelqu'un a capturé peut être renvoyé plus tard.
Une clé secrète Stripe active dans votre frontend est l'autre façon dont les paiements tournent mal, et c'est celle que nous voyons de l'extérieur : nous en avons trouvé une dans 3 des 30 998 applis scannées. Notre scan gratuit lit le JavaScript de votre app en ligne comme le ferait un inconnu et nomme chaque clé qu'il trouve, en précisant si elle a sa place là. Il prend environ 20 secondes et ne demande aucun compte : scannez votre app.
Si un faux paiement passe un jour
Une vérification retirée par un prompt ne s'annonce pas. Un builder à qui vous demandez de refaire marcher le webhook est libre de prendre le raccourci ci-dessus, et votre app continue de fonctionner pendant ce temps.
Reeve Monitor surveille ce qu'un inconnu peut voir de l'extérieur :
- les neuf vérifications toutes les heures, sur trois apps au plus
- un e-mail le jour où un nouveau scan note votre app moins bien que le précédent ; une clé secrète dans votre JavaScript plafonne à elle seule la note à D
- si l'app répond, toutes les 60 secondes
- un rapport mensuel de ce qui a été vu
Un faux événement écrit dans votre base de données comme un vrai. Reeve Care garde une copie de votre base Supabase d'avant.
- une copie chiffrée selon un calendrier, conservée sur le stockage de Reeve dans l'UE, en dehors de votre compte Supabase
- chaque copie vérifiée avant de compter, en comptant les lignes de chaque table
- une restauration en un clic qui sauvegarde ce qui est là avant de le remplacer
- vos fichiers téléversés aussi, dès que vous connectez un accès Storage
- tout ce que fait Monitor
Quoi faire maintenant
Que faire
- Ouvrez la livraison en échec dans le tableau de bord de Stripe et l'onglet Logs de votre fonction, et lisez le message exact avant de changer quoi que ce soit.
- Lisez le corps avec
await req.text()et passez cette chaîne intacte àconstructEventAsync. Ne vérifiez jamais un événement passé parJSON.stringify. - Copiez le secret
whsec_de l'endpoint depuis lequel Stripe envoie, dans le bon mode, et collez-le dans Edge Function Secrets sans rien derrière. - Gardez
verify_jwt = falsesur le webhook, et la vérification de signature comme première chose que fait la fonction. - Renvoyez un 400 quand la vérification échoue. N'interceptez jamais l'erreur pour traiter l'événement quand même.
Le reste de ce dont a besoin une fonction qui détient un secret se trouve dans déplacer un secret dans une Edge Function, et si votre erreur est l'erreur générique de supabase-js, lire un code de statut non-2xx indique où se trouve le vrai message.
FAQ
Que signifie "No signatures found matching the expected signature for payload" ?
Votre fonction de webhook a calculé la signature qu'elle attendait à partir du corps de la requête et de votre signing secret, et aucune des signatures de l'en-tête Stripe-Signature ne correspondait. La bibliothèque de Stripe lève cette erreur quand le corps qu'elle reçoit n'est pas, octet pour octet, ce que Stripe a envoyé, ou quand le secret n'est pas celui de l'endpoint qui a envoyé l'événement.
Pourquoi ça échoue quand je passe JSON.stringify de l'événement ?
Parce que la signature couvre le texte exact envoyé par Stripe, espaces et sauts de ligne compris. Lire le corps avec req.json() puis le retransformer en texte avec JSON.stringify vous donne les mêmes données dans un autre texte, et un autre texte a une autre signature. Lisez le corps une seule fois avec await req.text() et passez cette chaîne à constructEventAsync.
Quel signing secret utiliser ?
Le secret whsec_ de l'endpoint exact auquel Stripe livre. Chaque endpoint a le sien, la même adresse configurée en mode test et en mode live a un secret différent dans chacun, et la commande stripe listen affiche son propre secret, qui ne vaut que pour les événements qu'elle transfère. Votre clé API sk_ n'est pas du tout un signing secret.
Ma fonction de webhook Stripe doit-elle avoir verify_jwt désactivé ?
Oui. Stripe n'a aucun jeton Supabase à envoyer, donc avec la vérification active, chaque livraison est refusée avec un 401 avant que votre code ne tourne. L'exemple Stripe de Supabase dit lui-même de la déployer avec verify_jwt = false. La vérification de signature dans votre fonction devient alors la seule vérification qui existe, et elle ne doit jamais être sautée, ni interceptée puis ignorée.
Puis-je retirer la vérification de signature pour que les paiements marchent ?
Vous pouvez, et n'importe qui trouvant l'adresse de la fonction pourra alors lui envoyer un événement disant qu'un paiement a réussi. L'adresse est l'URL de votre projet, présente dans votre frontend, suivie du nom de la fonction. Sans la vérification, votre fonction croit chaque requête qu'elle reçoit et accorde ce que l'événement dit avoir été payé.