Segurança de webhook: assinaturas HMAC, idempotência e proteção de repetição
Três padrões de código interrompem cargas falsificadas, entregas duplicadas e solicitações de webhook reproduzidas. Exemplos de Node.js com HMAC-SHA256 e verificações de carimbo de data/hora.
Seu manipulador de webhook aceita uma solicitação POST, analisa o JSON e executa a lógica de negócios. Isso funciona até que alguém envia uma carga forjada para seu endpoint. Ou até que o fornecedor tente novamente uma entrega e seu responsável cobre uma cliente duas vezes. Ou até que um invasor registre uma solicitação legítima e a reproduza seis horas depois.
Três proteções corrigem isso: verificação de assinatura HMAC, chaves de idempotência e rejeição de repetição baseada em carimbo de data/hora. Este tutorial cobre cada um deles com código Node.js funcional.
Como funciona a assinatura de webhook
Todos os principais provedores de webhook (Stripe, GitHub, Shopify, Twilio) assinam cargas úteis de saída com HMAC-SHA256. O O provedor combina o corpo bruto da solicitação com um segredo compartilhado, calcula um hash e envia esse hash em um cabeçalho. Seu servidor recalcula o hash com o mesmo segredo e compara. Se os hashes corresponderem, a carga útil é autêntica.
A fórmula de assinatura é assim:
HMAC-SHA256(secret, timestamp + "." + raw_body) = signature
Stripe envia a assinatura em Stripe-Signature. GitHub envia X-Hub-Signature-256.
O algoritmo é o mesmo; apenas o nome e o formato do cabeçalho são diferentes.
Verifique uma assinatura de webhook Stripe em Node.js
Listra Stripe-Signature cabeçalho contém um carimbo de data/hora (t=) e um ou mais versionados
assinaturas (v1=). Veja como verificá-lo sem o Stripe SDK:
const crypto = require('crypto');
function verifyStripeSignature(payload, header, secret) {
const parts = header.split(',');
const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
const signature = parts.find(p => p.startsWith('v1=')).slice(3);
const signedPayload = timestamp + '.' + payload;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
if (expected !== signature) {
throw new Error('Invalid signature');
}
// Replay protection: reject if older than 5 minutes
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) {
throw new Error('Timestamp too old');
}
return JSON.parse(payload);
}
Detalhes principais: Stripe concatena o carimbo de data/hora e o corpo bruto com um separador de ponto final. O v1
assinatura usa HMAC-SHA256. A verificação do carimbo de data/hora no final fornece proteção de repetição (abordada em detalhes abaixo).
Verifique uma assinatura de webhook do GitHub em Node.js
O formato do GitHub é mais simples. O X-Hub-Signature-256 cabeçalho contém sha256= seguido
pelo hexadecimal HMAC do corpo bruto:
const crypto = require('crypto');
function verifyGitHubSignature(payload, signatureHeader, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
const trusted = Buffer.from(expected, 'utf8');
const untrusted = Buffer.from(signatureHeader, 'utf8');
if (trusted.length !== untrusted.length) {
throw new Error('Invalid signature');
}
if (!crypto.timingSafeEqual(trusted, untrusted)) {
throw new Error('Invalid signature');
}
return JSON.parse(payload);
}
Perceber crypto.timingSafeEqual. Um padrão === comparação vaza informações de tempo.
Um invasor pode medir os tempos de resposta para adivinhar a assinatura correta, byte por byte. Comparação em tempo constante
elimina esse vetor.
Calcule assinaturas HMAC com a API hash do botoi
Se você precisar calcular ou verificar assinaturas HMAC-SHA256 de um script, pipeline de CI ou função sem servidor
sem importar crypto, use o /v1/hash/hmac ponto final:
curl -X POST https://api.botoi.com/v1/hash/hmac \\
-H "Content-Type: application/json" \\
-d '{
"text": "1712345678.{\\"event\\":\\"payment_intent.succeeded\\",\\"amount\\":4999}",
"key": "whsec_your_stripe_secret",
"algorithm": "sha256"
}'
Resposta:
{
"success": true,
"data": {
"hmac": "a3f2b8c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0",
"algorithm": "sha256"
}
}
Isso retorna o HMAC codificado em hexadecimal. Compare-o com o cabeçalho de assinatura do provedor de webhook.
O endpoint suporta ambos sha256 e sha512 algoritmos.
Crie um middleware Express para verificação HMAC
Envolva a verificação de assinatura em um middleware reutilizável para que cada rota do webhook obtenha proteção sem duplicar o código:
const crypto = require('crypto');
function webhookAuth(secret) {
return (req, res, next) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
if (!signature || !timestamp) {
return res.status(401).json({ error: 'Missing signature headers' });
}
// Step 1: Replay protection
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) {
return res.status(401).json({ error: 'Request too old' });
}
// Step 2: HMAC verification
const payload = JSON.stringify(req.body);
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + '.' + payload)
.digest('hex');
const trusted = Buffer.from(expected, 'utf8');
const untrusted = Buffer.from(signature, 'utf8');
if (trusted.length !== untrusted.length ||
!crypto.timingSafeEqual(trusted, untrusted)) {
return res.status(401).json({ error: 'Invalid signature' });
}
next();
};
}
// Mount on your webhook route
app.post('/webhooks/stripe', webhookAuth(process.env.STRIPE_WEBHOOK_SECRET), handler);
Este middleware rejeita solicitações com cabeçalhos ausentes, carimbos de data e hora obsoletos ou assinaturas inválidas antes do seu manipulador é executado. A janela de 300 segundos corresponde à tolerância padrão do Stripe.
Idempotência: pular entregas duplicadas
Os provedores de webhook tentam novamente entregas com falha. Se o seu servidor retornou 500 após processar o evento, mas antes enviando a resposta 200, o provedor envia o mesmo evento novamente. Sem idempotência, seu manipulador executa o efeitos colaterais duas vezes: cobranças duplas, e-mails duplicados, atualizações repetidas de inventário.
A solução: armazene cada ID de evento processado e ignore os eventos que você viu antes.
Idempotência na memória (desenvolvimento)
const processedEvents = new Map(); // Use Redis in production
async function handleWebhook(req, res) {
const eventId = req.body.id;
// Check if already processed
if (processedEvents.has(eventId)) {
return res.status(200).json({ status: 'duplicate', eventId });
}
// Mark as processing before side effects
processedEvents.set(eventId, Date.now());
try {
await processEvent(req.body);
return res.status(200).json({ status: 'processed', eventId });
} catch (err) {
// Remove on failure so retries work
processedEvents.delete(eventId);
return res.status(500).json({ error: 'Processing failed' });
}
}
O Map trabalha para o desenvolvimento local. Na produção, use um armazenamento compartilhado para que todas as instâncias do servidor
veja o mesmo conjunto de eventos processados.
Idempotência apoiada por Redis (produção)
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
async function handleWebhook(req, res) {
const eventId = req.body.id;
const lockKey = \`webhook:processed:\${eventId}\
SET NX é atômico: apenas um processo ganha o bloqueio. O TTL de 72 horas (259200 segundos)
remove automaticamente entradas antigas. Se o processamento falhar, o manipulador excluirá a chave para que a próxima tentativa possa ser bem-sucedida.
Proteção de repetição: rejeita solicitações obsoletas
A verificação HMAC confirma que a carga veio do provedor. Mas uma solicitação válida assinada capturada por um invasor de rede permanece válido para sempre, a menos que você adicione uma verificação de tempo. A proteção de repetição rejeita cargas úteis com carimbos de data/hora anteriores a um limite.
function rejectStaleRequests(req, res, next) {
const timestamp = parseInt(req.headers['x-webhook-timestamp'], 10);
if (isNaN(timestamp)) {
return res.status(401).json({ error: 'Missing timestamp' });
}
const now = Math.floor(Date.now() / 1000);
const age = now - timestamp;
// Reject anything older than 5 minutes or from the future
if (age > 300 || age < -30) {
return res.status(401).json({ error: 'Timestamp out of range' });
}
next();
}
A tolerância futura de 30 segundos lida com pequenos desvios de clock entre o servidor e o provedor. Qualquer coisa com mais de 5 minutos ou mais de 30 segundos no futuro será rejeitado.
Combine todos os três em um middleware
Aqui está o middleware completo que executa todas as três verificações em sequência: carimbo de data/hora, HMAC e, em seguida, idempotência.
const crypto = require('crypto');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
function secureWebhook(secret) {
return async (req, res, next) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const eventId = req.body?.id;
// 1. Reject missing headers
if (!signature || !timestamp || !eventId) {
return res.status(401).json({ error: 'Missing required headers or event ID' });
}
// 2. Replay protection: reject stale requests
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300 || age < -30) {
return res.status(401).json({ error: 'Timestamp out of range' });
}
// 3. HMAC verification
const payload = JSON.stringify(req.body);
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + '.' + payload)
.digest('hex');
const trusted = Buffer.from(expected, 'utf8');
const untrusted = Buffer.from(signature, 'utf8');
if (trusted.length !== untrusted.length ||
!crypto.timingSafeEqual(trusted, untrusted)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 4. Idempotency: skip duplicates
const lockKey = \`webhook:processed:\${eventId}\
A ordem é importante. Timestamp é a verificação mais barata (sem E/S), por isso é executada primeiro. A verificação HMAC é Limitado à CPU, mas ainda rápido. A idempotência requer uma chamada do Redis, por isso é executada por último. Esta sequência rejeita o maior número de solicitações com o menor custo.
Teste seu gerenciador de webhook com a caixa de entrada de webhook do botoi
Usar botões /v1/webhook/inbox para criar um URL descartável, calcule uma assinatura HMAC com
/v1/hash/hmace envie uma carga útil de teste assinada. Sem túneis, sem servidor local.
# 1. Create a webhook inbox
INBOX=\$(curl -s -X POST https://api.botoi.com/v1/webhook/inbox/create)
INBOX_ID=\$(echo \$INBOX | jq -r '.data.inbox_id')
INBOX_URL=\$(echo \$INBOX | jq -r '.data.url')
echo "Inbox URL: \$INBOX_URL"
# 2. Compute an HMAC signature for your test payload
PAYLOAD='{"id":"evt_001","event":"order.created","amount":2999}'
TIMESTAMP=\$(date +%s)
SIGNED_PAYLOAD="\$TIMESTAMP.\$PAYLOAD"
HMAC=\$(curl -s -X POST https://api.botoi.com/v1/hash/hmac \\
-H "Content-Type: application/json" \\
-d "{
\\"text\\": \\"\$SIGNED_PAYLOAD\\",
\\"key\\": \\"test_secret_key\\",
\\"algorithm\\": \\"sha256\\"
}" | jq -r '.data.hmac')
echo "HMAC: \$HMAC"
# 3. Send the signed payload to your inbox
curl -s -X POST "\$INBOX_URL" \\
-H "Content-Type: application/json" \\
-H "X-Webhook-Signature: \$HMAC" \\
-H "X-Webhook-Timestamp: \$TIMESTAMP" \\
-d "\$PAYLOAD"
# 4. Retrieve and inspect the payload
curl -s -X POST "https://api.botoi.com/v1/webhook/inbox/\$INBOX_ID/list" | jq '.data.payloads'
O endpoint da lista retorna todas as cargas recebidas:
[
{
"id": "p_abc123",
"received_at": "2026-04-05T14:30:00Z",
"body": {
"id": "evt_001",
"event": "order.created",
"amount": 2999
}
}
]
Este fluxo de trabalho permite testar o fluxo de assinatura completo na linha de comando. Troque o URL da caixa de entrada pelo seu endpoint de produção para executar testes de integração em CI.
Como Stripe, GitHub e Shopify assinam webhooks
| Provedor | Cabeçalho | Algoritmo | Formato de carga assinada | Carimbo de data e hora incluído |
|---|---|---|---|---|
| Listra | Stripe-Signature |
HMAC-SHA256 | t=UNIX.v1=HEX |
Sim |
| GitHub | X-Hub-Signature-256 |
HMAC-SHA256 | sha256=HEX |
Não |
| Shopify | X-Shopify-Hmac-Sha256 |
HMAC-SHA256 | Codificado em Base64 | Não |
| Twilio | X-Twilio-Signature |
HMAC-SHA1 | URL + parâmetros classificados | Não |
Stripe é o único que inclui um carimbo de data/hora com a assinatura, oferecendo proteção de repetição pronta para uso. Para GitHub e Shopify, adicione seu próprio cabeçalho de carimbo de data/hora ou verifique o horário de criação do evento no corpo da carga útil.
Checklist antes de ir para produção
- Armazene o segredo do webhook em uma variável de ambiente, não no código-fonte.
- Analise o corpo bruto da solicitação, não a versão analisada em JSON. O HMAC assina os bytes exatos no fio.
- Usar
crypto.timingSafeEqualpara comparação de assinaturas. Nunca use===. - Retorne 200 antes de executar o processamento lento. Coloque o trabalho na fila e confirme a entrega.
- Registrar solicitações rejeitadas (assinatura inválida, carimbo de data/hora obsoleto, duplicado) para depuração e alertas.
- Defina um TTL em seu armazenamento de idempotência. 72 horas cobrem a maioria das janelas de repetição do provedor.
- Teste com uma caixa de entrada descartável e assinaturas HMAC computadas antes de conectar um provedor ativo.
FAQ
- Por que usar HMAC-SHA256 em vez de um segredo compartilhado em um parâmetro de consulta?
- Um parâmetro de consulta viaja em texto simples através de proxies e logs de acesso. O HMAC-SHA256 assina todo o corpo da solicitação com o segredo, portanto, um invasor que intercepte a URL ainda não poderá forjar uma assinatura válida.
- Por quanto tempo devo manter IDs de eventos processados para idempotência?
- Guarde-os por pelo menos 24 a 72 horas. A maioria dos provedores de webhook tenta novamente nessa janela. Após 72 horas, você pode remover IDs antigos de sua loja com segurança.
- Que tolerância de carimbo de data/hora devo usar para proteção de repetição?
- Cinco minutos (300 segundos) é o padrão. Stripe usa 300 segundos. Janelas mais curtas correm o risco de rejeitar entregas legítimas atrasadas pelo congestionamento da rede.
- Posso usar o endpoint botoi hash/hmac para verificar webhooks recebidos?
- Sim. POSTE o corpo da carga útil e seu segredo em /v1/hash/hmac com o algoritmo sha256. Compare o HMAC retornado com o cabeçalho de assinatura do provedor de webhook.
- Preciso das três proteções ou posso escolher uma?
- A verificação HMAC é a mínima. Adicione idempotência se o seu manipulador de webhook desencadear efeitos colaterais, como cobranças ou e-mails. Adicione proteção de reprodução se o seu sistema lidar com transações financeiras ou eventos sensíveis à segurança.
Comece a construir com botoi
150+ endpoints de API para consultas, processamento de texto, geração de imagens e utilitários para desenvolvedores. Plano gratuito, sem cartão de crédito.