Webhook seguro: validação de assinatura, retries e idempotência
1. Fundamentos de Webhooks e Riscos de Segurança
Webhooks são callbacks HTTP automatizados que notificam sistemas sobre eventos em tempo real, diferentemente do polling tradicional onde o cliente consulta periodicamente o servidor. Essa abordagem reduz latência e tráfego, mas introduz riscos específicos de segurança.
Os principais vetores de ataque incluem:
- Interceptação: invasores capturam o payload durante a transmissão
- Replay attack: um evento legítimo é reenviado múltiplas vezes para causar duplicação
- Falsificação de origem: atacantes enviam requisições fingindo ser o emissor legítimo
Um cenário típico de ataque envolve um atacante que intercepta um webhook de pagamento aprovado e o reenvia dezenas de vezes, creditando valores múltiplos na conta de destino. Sem mecanismos de segurança, o sistema receptor processa cada requisição como um evento válido.
2. Validação de Assinatura com HMAC
A validação de assinatura usa uma chave secreta compartilhada entre emissor e receptor, aplicando HMAC (Hash-based Message Authentication Code) com SHA-256.
Geração da assinatura no emissor
O emissor calcula o HMAC do payload JSON e inclui no cabeçalho:
POST /webhook HTTP/1.1
Host: receptor.exemplo.com
Content-Type: application/json
X-Signature-256: sha256=abc123def456...
X-Timestamp: 1700000000
X-Nonce: 7a8b9c0d1e2f
{"evento": "pagamento.criado", "valor": 100}
Implementação da verificação no receptor
import hmac
import hashlib
import time
CHAVE_SECRETA = "sua_chave_secreta_aqui"
JANELA_TEMPO = 300 # 5 minutos
def verificar_assinatura(payload, assinatura_recebida, timestamp, nonce):
# 1. Verificar timestamp contra replay
if abs(time.time() - timestamp) > JANELA_TEMPO:
return False
# 2. Construir mensagem com timestamp e nonce
mensagem = f"{timestamp}.{nonce}.{payload}"
# 3. Calcular HMAC esperado
assinatura_esperada = hmac.new(
CHAVE_SECRETA.encode(),
mensagem.encode(),
hashlib.sha256
).hexdigest()
# 4. Comparação segura contra timing attacks
return hmac.compare_digest(
f"sha256={assinatura_esperada}",
assinatura_recebida
)
A inclusão de timestamp e nonce no cálculo da assinatura impede ataques de replay: mesmo que o payload seja reenviado, o timestamp expirado ou o nonce repetido invalidam a requisição.
3. Implementação de Retries com Backoff Inteligente
Emissores confiáveis implementam retry automático quando o receptor retorna erro. A estratégia de backoff exponencial com jitter evita sobrecarga:
import random
import time
BASE_DELAY = 2 # segundos
MAX_RETRIES = 5
MAX_TOTAL_TIME = 300 # 5 minutos
def calcular_delay(tentativa):
delay = BASE_DELAY * (2 ** tentativa)
jitter = random.uniform(0, delay * 0.1)
return min(delay + jitter, MAX_TOTAL_TIME)
for tentativa in range(MAX_RETRIES):
resposta = enviar_webhook(payload)
if resposta.status_code == 200:
break # Sucesso
if resposta.status_code >= 400 and resposta.status_code < 500:
break # Erro do cliente, não retentar
delay = calcular_delay(tentativa)
time.sleep(delay)
Regras importantes:
- Códigos 5xx (erro do servidor) disparam retry
- Códigos 4xx (erro do cliente) não disparam retry
- Limite máximo de 5 tentativas em até 5 minutos
- Logging de todas as falhas para auditoria
Após exceder o limite, o evento deve ser enviado para uma dead letter queue para análise manual.
4. Idempotência na Entrega de Webhooks
Mesmo com retries controlados, o mesmo evento pode ser entregue múltiplas vezes. A idempotência garante que cada evento seja processado exatamente uma vez.
Implementação com chave de idempotência
O emissor inclui um cabeçalho Idempotency-Key único por evento:
POST /webhook HTTP/1.1
Idempotency-Key: evt_20231115_001
X-Signature-256: sha256=...
Verificação no receptor
import redis
cache = redis.Redis(host='localhost', port=6379, db=0)
TEMPO_EXPIRACAO = 86400 # 24 horas
def processar_com_idempotencia(payload, idempotency_key):
# 1. Verificar se chave já foi processada
if cache.exists(f"processed:{idempotency_key}"):
return {"status": "already_processed", "http_code": 409}
# 2. Registrar chave com lock atômico
if cache.setnx(f"lock:{idempotency_key}", "1"):
try:
# 3. Processar evento
resultado = processar_evento(payload)
# 4. Marcar como processado
cache.setex(
f"processed:{idempotency_key}",
TEMPO_EXPIRACAO,
"1"
)
return {"status": "success", "http_code": 200}
finally:
cache.delete(f"lock:{idempotency_key}")
else:
return {"status": "in_progress", "http_code": 202}
Respostas HTTP:
- 200 OK — evento processado com sucesso
- 202 Accepted — evento em processamento (outra requisição simultânea)
- 409 Conflict — evento já processado anteriormente
5. Orquestração do Fluxo Completo de Recebimento
O pipeline de validação segue a ordem:
- Verificação de assinatura — rejeitar requisições não autenticadas
- Verificação de idempotência — evitar processamento duplicado
- Processamento do evento — lógica de negócio
- Resposta HTTP — indicar resultado
def webhook_handler(request):
# 1. Extrair cabeçalhos
assinatura = request.headers.get('X-Signature-256')
timestamp = int(request.headers.get('X-Timestamp', 0))
nonce = request.headers.get('X-Nonce', '')
idempotency_key = request.headers.get('Idempotency-Key', '')
# 2. Validar assinatura
if not verificar_assinatura(request.body, assinatura, timestamp, nonce):
return {"status": "invalid_signature", "http_code": 401}
# 3. Validar idempotência
resultado = processar_com_idempotencia(request.body, idempotency_key)
return resultado
Para payloads grandes, inclua um checksum SHA-256 do corpo no cabeçalho Content-MD5 ou X-Content-SHA256 para verificar integridade antes do processamento.
6. Boas Práticas de Operação e Manutenção
Rotação de chaves secretas
Implemente versionamento de chaves com sufixos:
CHAVES_SECRETAS = {
"v1": "chave_ativa_atual",
"v2": "chave_nova_em_transicao"
}
Mantenha chaves antigas por 30 dias para garantir que eventos em trânsito sejam validados.
Testes de integração
Simule cenários críticos:
# Teste 1: assinatura inválida
payload = {"evento": "teste"}
assinatura_invalida = "sha256=000000..."
assert webhook_handler(payload, assinatura_invalida)["http_code"] == 401
# Teste 2: evento duplicado
chave = "teste_duplicado"
primeiro = webhook_handler(payload, assinatura_valida, idempotency_key=chave)
segundo = webhook_handler(payload, assinatura_valida, idempotency_key=chave)
assert primeiro["http_code"] == 200
assert segundo["http_code"] == 409
Ferramentas de debug
- webhook.site — testa recebimento de webhooks com interface visual
- ngrok — expõe servidor local para receber webhooks de serviços externos
- Logs estruturados — registre timestamp, idempotency_key, código HTTP e tempo de processamento
Documente o contrato completo do webhook: formato do payload, cabeçalhos obrigatórios, política de retry e códigos de resposta esperados.
Referências
- Webhook Security Best Practices - Svix — Guia completo sobre validação de assinatura, HMAC e prevenção de replay attacks
- Idempotency Key Specification - Stripe — Documentação oficial sobre implementação de chaves de idempotência em APIs
- HMAC SHA-256 Signature Verification - GitHub Webhooks — Tutorial prático de verificação de assinatura com exemplos em múltiplas linguagens
- Retry Strategy with Exponential Backoff - AWS — Artigo técnico sobre estratégias de retry com backoff exponencial e jitter
- Webhook Security Checklist - Standard Webhooks — Checklist de segurança para implementação de webhooks com exemplos de código e boas práticas