Validação de assinatura da Twilio falhou — descubra por quê
Você tem um cabeçalho X-Twilio-Signature, seu código diz que ele não bate, e a documentação da Twilio responde com um SDK para instalar. Isso não diz qual está errado: a URL, os parâmetros ou o token. Então cole os três abaixo: recalculamos a assinatura para cada forma plausível de URL e mostramos a que bate. A linha que bate é o seu diagnóstico.
Resumindo: uma assinatura da Twilio falha quase sempre porque a URL que o seu código reconstrói não é idêntica, byte a byte, à URL que a Twilio chamou — normalmente http:// em vez de https:// atrás de um proxy reverso. O algoritmo raramente é o problema.
Verifique uma assinatura que a Twilio rejeitou
A string exata que a Twilio assina
Todas as outras seções desta página decorrem desta. A Twilio não assina o seu corpo, nem os seus cabeçalhos. Ela assina uma única string, montada assim:
- Comece pela URL completa que a Twilio chamou: esquema, host, porta se ela estava explícita, caminho e query string.
- Ordene os nomes dos parâmetros POST em ordem ASCII.
- Para cada um, acrescente o nome seguido imediatamente do valor. Sem separador, sem sinal de igual, sem codificação, nada no meio.
- Calcule o HMAC-SHA1 da string inteira com o seu Auth Token.
- Codifique o digest em Base64. Isso é o X-Twilio-Signature.
O mesmo, com valores reais
https://api.example.com/twilio/sms
AccountSid=AC1234567890abcdef Body=Hello From=%2B15017122661 To=%2B15558675310
https://api.example.com/twilio/smsAccountSidAC1234567890abcdefBodyHelloFrom+15017122661To+15558675310
12345678901234567890123456789012
REsmqJpWLoqe1Q/VJXTZjx4eviY=
Repare no que não está ali. Nenhum &, nenhum =, nenhuma codificação percentual: o valor é +15017122661, não %2B15017122661. Quem reconstrói os parâmetros dividindo o corpo bruto por conta própria costuma esquecer de decodificar, e obtém uma assinatura errada por um motivo que nenhuma mensagem de erro vai mencionar.
Mude um único byte desses 101 caracteres e a assinatura muda por completo. A história inteira é essa: o seu código e a Twilio precisam montar exatamente a mesma string, e qualquer proxy, roteador ou middleware no caminho pode alterá-la sem avisar.
Quatro URLs, quatro assinaturas, uma verdade
Mesmo Auth Token, mesmos quatro parâmetros de cima. Só a URL muda — e isso basta:
| URL usada para montar a string | Assinatura resultante | De onde vem |
|---|---|---|
| https://api.example.com/twilio/sms | REsmqJpWLoqe1Q/VJXTZjx4eviY= | O que a Twilio assina |
| http://api.example.com/twilio/sms | NpKfrAiY/l6i2mXxfWlrjyRkytY= | O que o seu app vê atrás da terminação SSL |
| https://api.example.com:443/twilio/sms | vMbUG7KsMVUK80lzK6a+4IMFPqU= | Como fica uma URL montada à mão |
| https://api.example.com/twilio/sms/ | 8LRHF+mPXykd0Jij9WhgsI7agMs= | O que um roteador que normaliza caminhos entrega |
Nada na falha diz em qual das quatro você está. É isso que a ferramenta faz: calcula todas, até dezesseis formas de URL, e compara cada uma com a assinatura que a Twilio realmente enviou. A linha verde é a resposta.
As 8 causas reais
Ordenadas pela frequência com que aparecem no fórum da comunidade Twilio e nas threads do StackOverflow sobre este erro. Essa ordem é a nossa leitura, não uma estatística medida — use-a como ordem de busca, não como fato.
| # | Causa | O sintoma que a denuncia | Correção |
|---|---|---|---|
| 1 | Terminação SSL (X-Forwarded-Proto) | Passa com curl em localhost, falha assim que fica atrás de um load balancer. Seu log mostra http://, o Console mostra https://. | Confie no proxy: app.set('trust proxy', true), ProxyFix(app.wsgi_app, x_proto=1), TrustedProxies no Laravel. |
| 2 | Porta explícita na URL reconstruída | Seu log mostra https://host:443/… — a Twilio nunca escreve uma porta implícita. | Monte a partir do cabeçalho Host / X-Forwarded-Host. Não concatene hostname e porta. |
| 3 | Query string removida ou recodificada | Só falham as URLs com ?params. Seu framework devolveu o padrão da rota, não o alvo bruto. | Use o alvo bruto da requisição (req.originalUrl, request.url) e não mexa na codificação. |
| 4 | Parâmetros alterados antes da validação | Falha só em algumas mensagens — as que têm um campo vazio ou espaço no final. | Valide antes que qualquer middleware toque no corpo. Python: parse_qs(…, keep_blank_values=True). Laravel: exclua a rota do TrimStrings. |
| 5 | Corpo JSON validado como form data | Webhooks de Conversations e Studio falham, webhooks de SMS simples passam. | Use validateRequestWithBody() e o parâmetro de query bodySHA256. |
| 6 | Auth Token secundário não promovido | Começou a falhar logo após uma rotação de token, em 100% das requisições. | Console → Account → Auth Token: um token secundário não assina nada até você promovê-lo a Primary. |
| 7 | Parâmetros repetidos (bug do SDK) | Só falham mensagens em grupo e Conversations, e de forma não reproduzível. | twilio-node #722: params['MessagingBinding.Address'].sort() antes de validateRequest(). |
| 8 | Rewrite do proxy mudando o caminho | Falha só em produção. A Twilio chama /webhooks/sms, seu app recebe /sms. | Valide contra o caminho público, não o interno que sobra depois do rewrite. |
O bug dos parâmetros repetidos — a culpa não é sua
Este merece uma seção própria, porque dá para ler a documentação da Twilio por uma hora sem encontrá-lo. Mensagens em grupo e Conversations enviam a mesma chave várias vezes:
MessagingBinding.Address=%2B15558675310&MessagingBinding.Address=%2B15017122661
O servidor da Twilio ordena as chaves e depois concatena. O seu mapa de parâmetros contém um array. O twilio-node faz data += key + params[key], e o JavaScript converte esse array em string juntando os itens com vírgulas — na ordem de chegada. Se o seu framework leu os dois endereços em uma ordem diferente da usada pelo servidor da Twilio, as duas strings diferem e a validação falha. Mesmo token, mesma URL, mesmos parâmetros. Falha mesmo assim.
| Duas ordens, duas assinaturas — todo o resto idêntico | |
|---|---|
| Ordem de chegada: +15558675310 e depois +15017122661 | LJaNX3wj9/RRc4+GG1kwgPndM1c= |
| Ordenado: +15017122661 e depois +15558675310 | A7jNj66u8x1pDaqy3CNma9Q0XvA= |
// twilio-node issue #722
// Parâmetros repetidos chegam como array; ordene antes de validar.
if (Array.isArray(params['MessagingBinding.Address'])) {
params['MessagingBinding.Address'].sort();
}
const valid = twilio.validateRequest(authToken, signature, url, params);A ferramenta testa as duas ordens automaticamente. Se a linha ordenada ficar verde, você encontrou um bug no SDK, não no seu código — e pode parar de reler o seu handler.
Funciona localmente, quebra em produção
A história mais comum de todas, e ela tem uma única causa: em dev, o seu túnel entrega a requisição ao app no mesmo esquema que a Twilio usou. Em produção, Traefik, nginx ou Cloudflare terminam o TLS e repassam HTTP simples na porta 3000. O seu framework informa o que vê no próprio socket — http:// — e assina uma string que a Twilio nunca montou.
// Atrás de Traefik / nginx / Cloudflare
app.set('trust proxy', true);
// req.protocol agora segue o X-Forwarded-Proto
const url = req.protocol + '://' + req.get('host') + req.originalUrl;from werkzeug.middleware.proxy_fix import ProxyFix app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) # request.url agora reconstrói https:// validator.validate(request.url, request.form, signature)
// app/Http/Middleware/TrustProxies.php protected $proxies = '*'; // só se o proxy for seu protected $headers = Request::HEADER_X_FORWARDED_ALL;
Uma linha, três ecossistemas. Com o proxy marcado como confiável, o framework lê X-Forwarded-Proto e X-Forwarded-Host, reconstrói https://, e a assinatura bate. Só confie em proxies que você realmente controla: com trust proxy em true, qualquer coisa à frente pode declarar o esquema que quiser.
Corpos JSON: bodySHA256
Os webhooks da Twilio são application/x-www-form-urlencoded por padrão — nunca JSON. Mas Conversations e Studio podem enviar JSON, e aí as regras mudam: o corpo simplesmente não está nos parâmetros. A Twilio calcula o hash dele com SHA-256, acrescenta esse hash à URL como parâmetro de query bodySHA256 e assina a URL com uma lista de parâmetros vazia.
const valid = twilio.validateRequestWithBody( process.env.TWILIO_AUTH_TOKEN, req.headers['x-twilio-signature'], fullUrl, // ainda precisa ter ?bodySHA256=… rawBody // a string bruta, NÃO um JSON.parse seguido de stringify );
Duas armadilhas aqui. Remova a query string da URL e você remove também o bodySHA256, então a verificação falha sem nunca mencionar o corpo. E se o seu framework parseou o JSON antes de você, JSON.stringify() não vai devolver os bytes originais — a ordem das chaves e os espaços se perderam. Capture o corpo bruto. O modo JSON da ferramenta acima mostra os dois hashes lado a lado para você ver qual das duas armadilhas te pegou.
Fatos sobre a Twilio que vale a pena ter em mente
Detalhes que decidem se a assinatura chega a ter a chance de ser verificada:
- HMAC-SHA1, não SHA256. Stripe, GitHub e Shopify usam SHA-256, então copiar um handler que funciona de um deles e trocar o nome do cabeçalho produz um validador que nunca bate com nada.
- O payload é
application/x-www-form-urlencodedpor padrão, nunca JSON. Se o seu framework só parseia corpos JSON, o seu mapa de parâmetros fica vazio e toda assinatura falha. - A Twilio espera 15 segundos por um webhook de voz, 5 segundos no Conversations. Valide primeiro, responda, e depois faça o trabalho lento — uma verificação de assinatura que roda depois de uma chamada ao banco pode estourar o tempo antes de retornar.
- Uma nova tentativa por padrão, e até cinco com connection overrides. Então uma assinatura ruim não é uma mensagem com falha, é uma mensagem perdida depois de algumas tentativas.
- O Debugger guarda os logs por 30 dias. É lá que você encontra a URL que a Twilio realmente chamou, que é a entrada de que esta página precisa.
- Em conexões WebSocket o cabeçalho chega em minúsculas:
x-twilio-signature. Compare cabeçalhos sem diferenciar maiúsculas e minúsculas.
E o erro 57012 da Twilio?
O erro 57012 (assinatura inválida) diz que a assinatura não bate com a que a Twilio calculou, e aponta os suspeitos de sempre: token errado, URL errada, parâmetros alterados no caminho, hash do corpo ausente. Tudo verdade. Mas ele para exatamente onde a depuração começa, porque não consegue dizer qual deles se aplica à sua requisição. Note também que, do lado da Twilio, as causas 1, 2, 3, 7 e 8 acima são todas apenas “URL errada” — cinco bugs diferentes, cinco correções diferentes, um só código de erro. Separá-los é o objetivo desta página.
Quando usar o quê — com honestidade
Não somos a resposta para tudo aqui, e fingir o contrário desperdiçaria a sua tarde.
Você está escrevendo o handler do zero e quer o algoritmo de referência e o SDK da sua linguagem. É a fonte da verdade — webhooks-security cobre sete linguagens. Ela não tem nenhuma seção de troubleshooting, e é por isso que você veio parar aqui.
O seu problema não é uma requisição quebrada, é a entrega: você quer retries sob o seu controle, uma dead letter queue, timeouts além dos 15 segundos da Twilio, transformação de form para JSON, ou verificação de assinatura na borda para que o seu app nunca veja uma requisição não verificada. Isso é uma plataforma, e das boas. Pague por ela.
Uma assinatura, agora, e você quer saber qual linha do seu código mente. Trinta segundos, nada para instalar, sem conta. Depois você vai corrigir a config do proxy e esquece que a gente existe. É um ótimo desfecho.
Se webhooks são parte permanente da sua stack, e não um incêndio que você está apagando, o nosso túnel Relay encaminha o tráfego real da Twilio para localhost, e o testador de webhooks da Twilio reenvia requisições assinadas ao seu handler para você conferir a correção sem gastar um SMS de verdade.
Perguntas frequentes
Por que a minha assinatura da Twilio falha atrás do ngrok ou de um proxy?
Porque o proxy termina o TLS e repassa HTTP simples ao seu app. A Twilio assinou a URL https://; o seu framework reconstrói http:// a partir do próprio socket. As strings diferem, o HMAC difere. Marque o proxy como confiável para que o X-Forwarded-Proto seja usado na reconstrução da URL, e ela volta a bater.
A porta deve entrar na URL?
Só se a Twilio a usou na chamada. A Twilio assina a URL como foi configurada, então um 443 padrão nunca aparece na string. Acrescente :443 por conta própria ao reconstruir e você obtém outra assinatura. Reconstrua o host a partir do cabeçalho Host em vez de concatenar hostname e porta.
Como validar um corpo JSON vindo da Twilio?
Com validateRequestWithBody() em vez de validateRequest(). A Twilio calcula o hash do corpo com SHA-256, coloca esse hash em um parâmetro de query bodySHA256 e assina a URL sem parâmetros. Você precisa dos bytes brutos do corpo e da URL completa, incluindo esse parâmetro de query.
Por que funciona localmente mas não em produção?
Em dev, o seu túnel normalmente entrega ao app o mesmo esquema que a Twilio usou. Em produção, Traefik, nginx ou Cloudflare entregam HTTP simples em uma porta interna. Mesmo código, URL reconstruída diferente, então a assinatura deixa de bater. É a causa número um desta página.
O meu Auth Token é enviado para os servidores de vocês?
Não. O HMAC roda no seu navegador com WebCrypto, e a ferramenta não envia nenhuma requisição enquanto você a usa. Abra a aba de rede e confira. O seu Auth Token é um segredo de produção — você deveria fazer essa pergunta a todo site que o pede, inclusive o nosso.
A assinatura da Twilio é SHA-256, como a da Stripe?
Não, a Twilio usa HMAC-SHA1 e codifica em base64. Stripe, GitHub e Shopify usam SHA-256. Adaptar um handler da Stripe que funciona só renomeando o cabeçalho gera um validador que rejeita tudo, e é um caminho comum para chegar a este erro.