Pular para o conteúdo

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.

Vlad Tkachenko10 min de leitura
Um envelope fechado com um selo de cera turquesa e, ao lado, um sinete cujo desenho não bate com a marca, destacado em amarelo.

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.

A assinatura cobre o texto que o Stripe enviou, não os dados dentro dele. Um texto reescrito com dados idênticos não passa na conferência.

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 listen mostra. Quando você repassa eventos para a sua máquina com a ferramenta de linha de comando do Stripe, ela mostra um secret whsec_ 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_… ou sk_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.

Cada endpoint, cada modo e a ferramenta de linha de comando assinam com o próprio secret. Só o que assinou este evento consegue conferi-lo.

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 dizemO que aconteceu
No signatures found matching the expected signature for payloadO corpo foi alterado, ou o secret é o errado. Este artigo.
Webhook payload must be provided as a string or a BufferVocê 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 zoneO 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 para constructEventAsync. Nunca confira um evento que passou por JSON.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 = false no 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.

Escrito por

Vlad Tkachenko

Fundador da Reeve

Passo meus dias olhando apps criados com Lovable, Bolt, v0, Cursor e Replit, e a curta lista de erros que aparecem neles sem parar.

Mais sobre o autor

Leia depois

Todos os artigos

Faça backup do seu banco Supabase antes da próxima mudança

O Reeve guarda uma cópia criptografada em horários programados e a restaura para você se algo quebrar.

Ver backups do Supabase

Verificação externa automatizada, não uma auditoria completa. A ausência de achados não é garantia de segurança.