Noções de segurança
"No signatures found matching the expected signature" no Supabase
O webhook do Stripe numa Edge Function do Supabase diz "No signatures found matching the expected signature"? São duas causas. Continue verificando.

Em resumo
- "No signatures found matching the expected signature for payload" quer dizer que sua função conferiu a assinatura do Stripe e ela não bateu. Ou o corpo foi alterado antes da conferência, ou o signing secret não é aquele com que o Stripe assinou.
- Leia o corpo com await req.text() e passe esse texto, intacto, para constructEventAsync. Depois compare o secret whsec_ que sua função usa com o do endpoint exato de onde o Stripe está enviando.
- Não faça o erro sumir pulando a conferência. O webhook precisa rodar com verify_jwt = false, então a assinatura é a única coisa que impede um estranho de dizer ao seu app que um pagamento foi aprovado.
Você conectou o Stripe ao seu app, um pagamento de teste passou, e o webhook que deveria marcar o pedido como pago não fez nada. O painel do Stripe mostra a entrega falhando, e os logs da sua Edge Function do Supabase dizem isto:
No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?
A boa notícia está na origem do erro. Ele é lançado pela parte da sua função que confere se a requisição veio mesmo do Stripe, ou seja, essa conferência existe e está fazendo o trabalho dela. "No signatures found matching the expected signature for payload" tem duas causas, e nenhuma se resolve conferindo menos. A correção que aparece primeiro quando se pesquisa, embrulhar a conferência para o erro ser ignorado, é justamente a que deixa qualquer pessoa marcar um pedido como pago.
O que significa "No signatures found matching the expected signature"?
Sua função conferiu a assinatura do Stripe na requisição, e ela não bateu.
Pense em cada webhook que o Stripe envia como uma carta fechada com um selo de
cera. O Stripe pressiona o selo com um sinete que só ele e você têm: o signing
secret do seu endpoint, um valor que começa com whsec_. Quando a carta chega,
sua função pressiona a própria cópia do sinete contra a cera. Se os desenhos
batem, a carta é do Stripe e ninguém mexeu nela no caminho.
O selo cobre a carta exatamente como foi escrita. A biblioteca do Stripe pega o
texto da requisição, o horário do cabeçalho Stripe-Signature e o seu secret,
calcula como a assinatura deveria ser e procura por ela nesse cabeçalho. A
mensagem acima está escrita
no próprio stripe-node,
e é o que ele lança quando nada ali bate. A
página do Stripe sobre erros de assinatura
diz sem rodeios: pelo menos um dos três valores que você passou está errado.
Então há dois lugares para olhar. Ou a carta foi alterada antes de a sua função conferir o selo, ou a sua função está com o sinete errado.
Causa 1: o corpo foi alterado antes da conferência
Numa Edge Function, a versão mais comum é um corpo lido primeiro como JSON e depois transformado de volta em texto para a conferência.
const event = await req.json()
await stripe.webhooks.constructEventAsync(JSON.stringify(event), signature, secret, undefined, cryptoProvider)
Parece natural e falha toda vez. req.json() fica com os dados e joga fora o
texto: os espaços, as quebras de linha, a ordem em que o Stripe escreveu as
coisas. JSON.stringify então escreve um texto novo com os mesmos dados dentro.
O selo foi pressionado sobre o texto do Stripe, e isto é a cópia de outra pessoa,
com outra letra, então nunca vai bater.
O próprio exemplo de webhook do Stripe do Supabase faz diferente: lê o corpo uma vez só, como texto, e repassa esse texto sem mexer:
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,
)
O comentário nesse exemplo diz por quê: a verificação depende do corpo bruto da
requisição, e não do JSON já interpretado. Você não precisa interpretar nada
depois, porque constructEventAsync devolve o evento já interpretado assim que o
selo confere.
O mesmo acontece fora do seu código. A página do Stripe cita frameworks que interpretam ou reformatam um corpo antes de o seu código vê-lo, e a mensagem do stripe-node acrescenta que uma ferramenta que repassa webhooks para você precisa preservar o corpo exato, incluindo a formatação do JSON e o tipo de quebra de linha. Se há um proxy ou um serviço de repasse entre o Stripe e a sua função, confira se ele entrega o corpo exatamente como chegou.
Causa 2: o signing secret é o errado
Se o corpo está intacto, o sinete é o errado, e a página do Stripe diz que essa é a causa mais comum de todas.
Três valores diferentes podem acabar no lugar do signing secret, e eles se parecem:
- O secret de outro endpoint. O guia de webhooks do Stripe diz que cada endpoint recebe o seu próprio secret e que o mesmo endereço configurado no modo de teste e no modo live tem um secret diferente em cada um. Um pagamento live conferido com o secret do endpoint de teste falha, e o contrário também.
- O secret que o
stripe listenmostra. Quando você repassa eventos para a sua máquina com a ferramenta de linha de comando do Stripe, ela mostra um secretwhsec_próprio. O Stripe diz que ele é diferente do secret do endpoint do painel e que os dois são confundidos. Ele funciona para os eventos que a ferramenta repassa, e para mais nada. - Sua chave de API do Stripe.
sk_live_…ousk_test_…é a chave com que a sua função chama o Stripe. Não é um signing secret, e uma conferência feita com ela nunca vai bater.
Para achar o certo, abra o endpoint no painel do Stripe em Workbench, depois
Webhooks, e clique em Reveal secret. Antes, veja em qual modo você está.
Depois compare com o que a sua função lê. No exemplo acima é
STRIPE_WEBHOOK_SIGNING_SECRET, definido na página Edge Function Secrets do
seu projeto Supabase ou com supabase secrets set.
Já que você está lá, confira mais uma coisa. Se o valor foi colado com um espaço ou uma quebra de linha no fim, o stripe-node acrescenta uma nota ao mesmo erro: "The provided signing secret contains whitespace." Apague o valor e cole de novo, sem nada depois.
As mensagens que parecem iguais e querem dizer outra coisa
O stripe-node lança vários erros da mesma conferência, e cada um aponta para um problema diferente. Todos estão escritos no código de webhooks dele:
| O que seus logs dizem | O que aconteceu |
|---|---|
No signatures found matching the expected signature for payload | O corpo foi alterado, ou o secret é o errado. Este artigo. |
Webhook payload must be provided as a string or a Buffer | Você passou o objeto interpretado de req.json(). Passe o texto de req.text(). |
No stripe-signature header value was provided. | A requisição veio sem o cabeçalho Stripe-Signature. Talvez nem tenha vindo do Stripe. |
Timestamp outside the tolerance zone | O selo bateu, mas o evento é mais antigo que o limite. As bibliotecas do Stripe usam 5 minutos. |
SubtleCryptoProvider cannot be used in a synchronous context. | Você chamou constructEvent. Com o provedor Web Crypto de que o Deno precisa, use constructEventAsync. |
E uma que nunca chega ao seu código. Se o painel do Stripe mostra a entrega falhando com um 401 e os logs da sua função estão vazios, a plataforma do Supabase recusou a requisição antes de a sua função rodar, por causa de uma verificação que precisa estar desligada num webhook.
Por que seu webhook precisa do verify_jwt desligado
Porque o Stripe não consegue provar quem é do jeito que os usuários do seu app provam, e com a verificação ligada toda entrega é recusada.
O Supabase põe na frente de cada Edge Function uma verificação chamada
verify_jwt, ligada por padrão. Ela pede um token do Supabase no cabeçalho
Authorization da requisição, e o Stripe não tem nenhum para enviar. O
guia de configuração de funções
do Supabase usa exatamente isso como exemplo, dizendo que webhooks do Stripe
precisam ser acessíveis publicamente, e desliga a verificação só para essa
função em supabase/config.toml:
[functions.stripe-webhook]
verify_jwt = false
O comando supabase functions deploy aceita --no-verify-jwt para a mesma
coisa. O exemplo de Stripe do Supabase traz a mesma instrução num comentário: o
Stripe verifica a requisição pela assinatura, então faça o deploy com
verify_jwt = false.
Com a verificação da plataforma desligada, qualquer requisição da internet chega ao seu código, e a conferência do selo dentro da sua função é a única coisa que separa as cartas do Stripe das de qualquer outra pessoa.
Por que você nunca deve pular a conferência de assinatura
Porque sem ela qualquer pessoa pode dizer ao seu app que um pagamento foi aprovado.
O endereço de uma função de webhook é a URL do seu projeto, que está no seu
frontend para qualquer um ler, seguida de /functions/v1/ e do nome da função,
que costuma ser o óbvio. O exemplo do guia de configuração chama a função de
stripe-webhook. Com o verify_jwt desligado, a plataforma deixa toda
requisição passar até o seu código. Se o seu código não confere mais o selo, ele
acredita em toda carta que chega.
Então um estranho envia para a sua função uma requisição dizendo
checkout.session.completed, com um pedido ou um cliente escolhido por ele. Sua
função faz o que faz com uma de verdade: marca o pedido como pago, sobe o plano
da conta, libera o download. Ninguém pagou. O Stripe não tem registro disso,
porque o Stripe nunca enviou.
Três jeitos de pular a conferência terminam todos aí:
- Apagá-la porque não passava.
- Capturar o erro e seguir em frente, registrando a falha e processando o evento mesmo assim. O exemplo devolve um 400 do bloco catch, e essa é a forma a manter: uma requisição que falha na conferência para ali.
- Colocar a tolerância de tempo em 0. O guia de webhooks do Stripe diz para não fazer isso: 0 desliga totalmente a verificação de idade, e um evento real que alguém capturou pode ser reenviado depois.
Uma chave secreta do Stripe ativa no seu frontend é o outro jeito de os pagamentos darem errado, e é o que conseguimos ver de fora: encontramos uma em 3 de 30.998 apps escaneados. A nossa varredura gratuita lê o JavaScript do seu app publicado como um estranho leria e aponta cada chave que encontra, dizendo se ela é das que devem estar ali. Leva uns 20 segundos e não precisa de conta: escaneie o seu app.
Se um pagamento falso passar algum dia
Uma conferência que um prompt removeu não avisa. Um builder a quem você pede para fazer o webhook voltar a funcionar pode pegar o atalho acima, e o seu app continua funcionando enquanto isso.
O Reeve Monitor acompanha o que um estranho consegue ver de fora:
- as nove verificações a cada hora, em até três apps
- um e-mail no dia em que uma nova varredura avaliar o seu app pior que a anterior; uma chave secreta no seu JavaScript limita sozinha a nota a D
- se o app está no ar, a cada 60 segundos
- um relatório mensal do que ele viu
Um evento falso grava no seu banco de dados como um verdadeiro. O Reeve Care guarda uma cópia do seu banco Supabase de antes de acontecer.
- uma cópia criptografada conforme uma agenda, guardada no armazenamento do próprio Reeve na UE, fora da sua conta Supabase
- cada cópia verificada antes de contar, contando as linhas de cada tabela
- uma restauração em um clique que salva o que está lá agora antes de substituir
- também os seus arquivos enviados, assim que você conectar uma credencial de Storage
- tudo o que o Monitor faz
O que fazer agora
O que fazer
- Abra a entrega que falhou no painel do Stripe e a aba Logs da sua função, e leia a mensagem exata antes de mudar qualquer coisa.
- Leia o corpo com
await req.text()e passe esse texto intacto paraconstructEventAsync. Nunca confira um evento que passou porJSON.stringify. - Copie o secret
whsec_do endpoint de onde o Stripe envia, no modo certo, e cole em Edge Function Secrets sem nada depois. - Mantenha
verify_jwt = falseno webhook, e a conferência de assinatura como a primeira coisa que a função faz. - Devolva um 400 quando a conferência falhar. Nunca capture o erro para processar o evento mesmo assim.
O resto do que uma função que guarda um secret precisa está em mover um secret para uma Edge Function, e se o seu erro é o genérico do supabase-js, ler um código de status non-2xx mostra onde está a mensagem real.
Perguntas frequentes
O que significa "No signatures found matching the expected signature for payload"?
Sua função de webhook calculou a assinatura que esperava a partir do corpo da requisição e do seu signing secret, e nenhuma das assinaturas do cabeçalho Stripe-Signature bateu. A biblioteca do Stripe lança esse erro quando o corpo que recebe não é, byte a byte, o que o Stripe enviou, ou quando o secret não é o do endpoint que enviou o evento.
Por que falha quando passo JSON.stringify do evento?
Porque a assinatura cobre o texto exato que o Stripe enviou, com espaços e quebras de linha. Ler o corpo com req.json() e transformá-lo de volta em texto com JSON.stringify dá os mesmos dados em outro texto, e outro texto tem outra assinatura. Leia o corpo uma vez com await req.text() e passe esse texto para constructEventAsync.
Qual signing secret eu uso?
O secret whsec_ do endpoint exato para o qual o Stripe entrega. Cada endpoint tem o seu, o mesmo endereço configurado no modo de teste e no modo live tem um secret diferente em cada um, e o comando stripe listen mostra um secret próprio, que só vale para os eventos que ele repassa. Sua chave de API sk_ não é um signing secret.
Minha Edge Function de webhook do Stripe deve ficar com verify_jwt desligado?
Sim. O Stripe não tem um token do Supabase para enviar, então com a verificação ligada toda entrega é recusada com um 401 antes de o seu código rodar. O próprio exemplo de Stripe do Supabase diz para fazer o deploy com verify_jwt = false. Isso faz da conferência de assinatura dentro da sua função a única verificação que existe, e ela nunca pode ser pulada nem capturada e ignorada.
Posso remover a conferência de assinatura para os pagamentos funcionarem?
Pode, e então qualquer pessoa que encontrar o endereço da função poderá enviar a ela um evento dizendo que um pagamento foi aprovado. O endereço é a URL do seu projeto, que está no seu frontend, mais o nome da função. Sem a conferência, sua função acredita em toda requisição que recebe e libera o que o evento disser que foi pago.