Webhook Signatures no Node.js verificam se uma requisição foi criada pelo provedor esperado e se o corpo não foi alterado durante o transporte. O padrão mais comum usa HMAC com um segredo compartilhado, timestamp e comparação em tempo constante.
HTTPS protege o canal, mas não prova por si só que o emissor conhece o segredo do webhook. Um endpoint público pode receber chamadas de qualquer origem. A assinatura adiciona autenticação da mensagem e ajuda a bloquear payloads forjados.
Formato típico
Webhook-Signature: t=1792780000,v1=3c68...O provedor calcula o HMAC sobre uma mensagem canônica, por exemplo:
${timestamp}.${rawBody}A aplicação precisa reproduzir exatamente a mesma sequência de bytes.
Preserve o corpo bruto
O erro mais comum é verificar depois que o framework transformou JSON em objeto. Espaços, ordem de propriedades e codificação podem mudar.
app.post('/webhooks/provider',
express.raw({ type: 'application/json', limit: '1mb' }),
webhookHandler
);Depois da verificação, faça o parsing.
const rawBody = req.body;
const payload = JSON.parse(rawBody.toString('utf8'));Calculando HMAC
import { createHmac, timingSafeEqual } from 'node:crypto';
function signWebhook({ secret, timestamp, rawBody }) {
return createHmac('sha256', secret)
.update(String(timestamp))
.update('.')
.update(rawBody)
.digest('hex');
}Comparação em tempo constante
function safeEqualHex(expectedHex, receivedHex) {
if (!/^[a-f0-9]+$/i.test(receivedHex)) return false;
const expected = Buffer.from(expectedHex, 'hex');
const received = Buffer.from(receivedHex, 'hex');
if (expected.length !== received.length) return false;
return timingSafeEqual(expected, received);
}timingSafeEqual exige buffers do mesmo tamanho. Valide formato antes da comparação.
Parsing do header
function parseSignatureHeader(value) {
const parts = String(value ?? '').split(',');
const parsed = new Map();
for (const part of parts) {
const [key, rawValue] = part.split('=', 2);
if (key && rawValue) parsed.set(key.trim(), rawValue.trim());
}
return {
timestamp: Number(parsed.get('t')),
signatures: parts
.filter(part => part.trim().startsWith('v1='))
.map(part => part.trim().slice(3))
};
}Aceitar múltiplas assinaturas facilita rotação de segredos e evolução de versão.
Proteção contra replay
Uma assinatura válida pode ser capturada e reenviada. Exija timestamp recente.
function validateTimestamp(timestamp, toleranceSeconds = 300) {
if (!Number.isSafeInteger(timestamp)) return false;
const now = Math.floor(Date.now() / 1000);
return Math.abs(now - timestamp) <= toleranceSeconds;
}Sincronize relógios e monitore desvios. Para operações críticas, persista o ID do evento e rejeite duplicações.
Verificação completa
function verifyWebhook({ header, rawBody, secrets }) {
const parsed = parseSignatureHeader(header);
if (!validateTimestamp(parsed.timestamp)) {
throw new Error('Timestamp inválido');
}
const valid = secrets.some(secret => {
const expected = signWebhook({
secret,
timestamp: parsed.timestamp,
rawBody
});
return parsed.signatures.some(received =>
safeEqualHex(expected, received)
);
});
if (!valid) throw new Error('Assinatura inválida');
}Rotação de segredo
Durante a rotação, aceite segredo atual e anterior por uma janela curta. O emissor pode assinar com ambos ou a aplicação pode testar os dois.
const webhookSecrets = [
process.env.WEBHOOK_SECRET_CURRENT,
process.env.WEBHOOK_SECRET_PREVIOUS
].filter(Boolean);Remova o segredo antigo depois de confirmar que todos os emissores usam o novo. Consulte Secret Management no Node.js.
Segredos por endpoint
Use segredo diferente por provedor, ambiente e, quando necessário, por tenant. Um segredo comprometido não deve permitir forjar todos os webhooks.
Assinaturas assimétricas
Alguns provedores usam RSA ou Ed25519. Nesse modelo, o emissor guarda a chave privada e consumidores validam com chave pública.
import { verify } from 'node:crypto';
const valid = verify(
null,
signedMessage,
publicKey,
signatureBuffer
);Siga exatamente o algoritmo, encoding e formato de chave documentados pelo provedor. Não escolha o algoritmo com base em header não confiável sem allowlist.
Resposta rápida
Depois de validar assinatura e persistir o evento, responda rapidamente. Processamento longo aumenta retries do provedor.
await inbox.insert({
eventId: payload.id,
type: payload.type,
body: payload
});
res.status(202).end();Um worker processa a Inbox de forma assíncrona.
Idempotência
Assinatura válida não impede entregas duplicadas. Crie constraint única por provedor e ID do evento.
CREATE UNIQUE INDEX webhook_inbox_unique
ON webhook_inbox (provider, event_id);Veja Consumer Idempotente no Node.js.
Ordem dos passos
- limite tamanho do corpo;
- capture bytes brutos;
- extraia timestamp e assinatura;
- valide tolerância temporal;
- calcule e compare assinatura;
- faça parsing do JSON;
- valide schema;
- persista com idempotência;
- responda;
- processe assíncronamente.
Erros HTTP
Use respostas consistentes sem revelar detalhes:
400: header ou corpo malformado;401ou400: assinatura inválida, conforme contrato;413: corpo grande;202ou200: evento aceito;500: falha transitória de persistência.
Não informe qual segredo ou parte da assinatura falhou.
Proxies e body parsers
Garanta que nenhum middleware consuma ou modifique o corpo antes do verificador. Compressão de request, conversão de charset e normalização também podem alterar os bytes assinados.
Logs
Registre provedor, ID do evento, tipo, resultado e latência. Nunca registre segredo, assinatura completa ou payload sensível. Para diagnóstico, um prefixo curto ou hash pode ser suficiente.
Rate limiting
Endpoints de webhook podem sofrer abuso. Limite tamanho, conexões e frequência, mas considere IPs variáveis e retries legítimos. A assinatura continua sendo a defesa principal de autenticidade.
Testes
- assinatura válida;
- corpo alterado;
- timestamp expirado;
- timestamp futuro;
- header ausente;
- hex inválido;
- segredo atual e anterior;
- payload duplicado;
- JSON inválido após assinatura válida;
- corpo acima do limite.
Use fixtures oficiais do provedor quando disponíveis.
Erros comuns
- assinar JSON reserializado;
- comparar strings com
===; - não verificar timestamp;
- não persistir IDs processados;
- usar o mesmo segredo em produção e teste;
- registrar assinatura completa;
- processar antes de responder;
- não planejar rotação;
- aceitar algoritmo arbitrário.
Checklist
- raw body preservado;
- limite de bytes;
- mensagem canônica documentada;
- timestamp validado;
- comparação constante;
- segredos rotacionáveis;
- schema validado;
- Inbox idempotente;
- resposta rápida;
- logs sem segredos.
Conclusão
Webhook Signatures no Node.js protegem endpoints públicos contra payloads forjados e alterações. A verificação correta depende dos bytes originais, do algoritmo exato e de uma janela temporal contra replay.
Combine assinatura com idempotência, validação de schema e processamento assíncrono. Com rotação de segredos e testes de fixtures reais, webhooks se tornam uma integração confiável em vez de uma rota pública difícil de auditar.
Consulte a documentação oficial do módulo crypto do Node.js e o RFC do HMAC.



