Aller au contenu

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.

Vlad Tkachenko10 min de lecture
Une enveloppe fermée par un cachet de cire turquoise et, à côté, un sceau dont le motif ne correspond pas à l'empreinte, marqué en jaune.

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 signature couvre le texte envoyé par Stripe, pas les données qu'il contient. Un texte réécrit avec des données identiques échoue à la vérification.

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 secret whsec_. 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_… ou sk_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.

Chaque endpoint, chaque mode et l'outil en ligne de commande signent avec leur propre secret. Seul celui qui a signé cet événement peut le vérifier.

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 logsCe qui s'est passé
No signatures found matching the expected signature for payloadLe corps a été modifié, ou le secret n'est pas le bon. Cet article.
Webhook payload must be provided as a string or a BufferVous 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 zoneLe 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é par JSON.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 = false sur 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é.

Écrit par

Vlad Tkachenko

Fondateur de Reeve

Je passe mes journées à examiner des applications créées avec Lovable, Bolt, v0, Cursor et Replit, et la courte liste d'erreurs qui y reviennent sans cesse.

En savoir plus sur l'auteur

À lire ensuite

Tous les articles

Sauvegardez votre base Supabase avant la prochaine modification

Reeve en garde une copie chiffrée à intervalles réguliers et la restaure pour vous en cas de problème.

Voir les sauvegardes Supabase

Vérification externe automatisée, pas un audit complet. L'absence de problème n'est pas une garantie de sécurité.