Para verificar uma assinatura de webhook, recalcule um HMAC exatamente sobre os bytes que o remetente assinou, usando o segredo que você compartilha com ele, e compare o seu valor com o cabeçalho de assinatura em tempo constante. Depois, confira se o carimbo de data e hora assinado é recente. Para webhooks da Elido, isso significa HMAC-SHA256 com chave no seu segredo whsec_... inteiro, sobre {X-Webhook-Timestamp}.{raw body}, codificado em hexadecimal, prefixado com v1=, e comparado com X-Elido-Signature.
Esse é o algoritmo inteiro. As falhas vêm dos detalhes ao redor dele: um analisador de corpo que rodou cedo demais, um segredo que foi decodificado, um resumo hexadecimal comparado com um em base64. Este post cobre a verificação de assinatura de webhook com HMAC em geral, depois código funcional da Elido para Node, Python, Go e um nó Code do n8n, além da janela contra repetição e da rotação de segredo que a maioria dos guias rápidos deixa de fora.
Se você ainda não configurou um endpoint, comece com webhooks para eventos de link, que lista todos os tipos de evento e o envelope da carga útil. Esta página continua a partir do momento em que uma solicitação assinada chega ao seu servidor.
Como as assinaturas HMAC de webhook funcionam
Um endpoint de webhook é uma URL pública. Qualquer pessoa que a encontre pode enviar por POST um corpo JSON que parece um evento real, então o receptor precisa de prova de origem. O HMAC oferece isso com baixo custo: remetente e receptor compartilham um segredo, o remetente calcula HMAC-SHA256(secret, message) e coloca o resultado em um cabeçalho, e o receptor faz o mesmo cálculo e compara. Sem o segredo, ninguém consegue produzir um valor correspondente, e mudar um único byte da mensagem muda o resumo inteiro.
Três detalhes variam entre provedores, e cada um deles quebra a verificação se você errar:
- O que entra na mensagem. O GitHub assina apenas o corpo bruto. A Stripe e a Elido assinam um carimbo de data e hora, um ponto e o corpo. A especificação Standard Webhooks assina um ID de mensagem, o carimbo de data e hora e o corpo.
- Como o resumo é codificado. Hexadecimal ou base64, com um prefixo de esquema como
v1=ousha256=. - Qual é a chave. Alguns provedores decodificam de base64 o segredo depois do prefixo. A Elido não faz isso: a chave é a string
whsec_completa como bytes UTF-8.
Colocar o carimbo de data e hora dentro da mensagem assinada importa. Isso impede que um invasor combine um corpo antigo, assinado de forma válida, com um cabeçalho de carimbo de data e hora novo, que é o que torna uma janela contra repetição realmente aplicável.
O que a Elido assina e quais cabeçalhos carregam isso
Toda entrega para um endpoint event ou siem é um POST com Content-Type: application/json e um corpo no formato {"type", "workspace_id", "data", "timestamp"}. Tipos de endpoint em formato de chat (Discord, Telegram, Sentry) autenticam pela própria URL e não carregam cabeçalhos HMAC, então tudo abaixo se aplica apenas aos dois primeiros tipos.
| Cabeçalho | Valor | O que fazer com ele |
|---|---|---|
X-Elido-Signature | v1= + 64 caracteres hex em minúsculas | Compare com o valor que você calculou |
X-Webhook-Signature | O mesmo valor acima | Apelido antigo; leia um ou outro, não ambos |
X-Webhook-Timestamp | Segundos Unix, por exemplo 1789000000 | Parte da mensagem assinada; verifique a idade |
X-Elido-Signature-Previous | v1= + hex, assinado com o segredo antigo | Presente só durante a janela de cortesia da rotação |
X-Webhook-Event | Nome do evento, por exemplo link.created | Encaminhe o evento (depois de verificar) |
X-Webhook-Delivery | ID numérico da entrega, estável entre tentativas | Chave de desduplicação para processamento idempotente |
O segredo é gerado para você quando o endpoint é criado: whsec_ seguido por 64 caracteres hexadecimais. Ele é retornado uma vez na resposta de criação e nunca mais, então vai direto para o seu cofre de segredos.
Aqui está um vetor de teste para você rodar contra o seu código. Com o segredo whsec_test_only_do_not_use, o carimbo de data e hora 1789000000 e o corpo {"type":"link.created","workspace_id":42}, o valor correto do cabeçalho é:
v1=b9369aa411a8b7ce705bcd5bba112dea9d72d2e787aa88959ff62f33942d1a15
Você pode reproduzi-lo a partir de um shell, que é meu primeiro passo sempre que um receptor discorda do remetente:
printf '%s.%s' 1789000000 '{"type":"link.created","workspace_id":42}' \
| openssl dgst -sha256 -hmac 'whsec_test_only_do_not_use' -r
Uma armadilha aqui: o campo timestamp do próprio corpo é a hora em que o evento aconteceu. O carimbo de data e hora assinado é o que está no cabeçalho X-Webhook-Timestamp, definido quando a solicitação é enviada. Não misture os dois.
Valide uma assinatura de webhook em Node
No Express, a correção para a maioria das falhas é uma linha: monte express.raw() na rota de webhook para que req.body seja um Buffer dos bytes exatos recebidos. Registre essa rota antes de qualquer app.use(express.json()) global, porque depois que o analisador JSON consumiu o fluxo, o analisador bruto não tem mais nada para ler.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.ELIDO_WEBHOOK_SECRET; // the full whsec_... string
const TOLERANCE_SEC = 300;
function matches(expected, got) {
const a = Buffer.from(expected);
const b = Buffer.from(got ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
const app = express();
app.post(
"/webhooks/elido",
express.raw({ type: "application/json" }),
(req, res) => {
const ts = req.get("X-Webhook-Timestamp") ?? "";
if (
!/^\d+$/.test(ts) ||
Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SEC
) {
return res.status(400).send("stale or missing timestamp");
}
const expected =
"v1=" +
createHmac("sha256", SECRET)
.update(`${ts}.`)
.update(req.body)
.digest("hex");
const ok = [
req.get("X-Elido-Signature"),
req.get("X-Elido-Signature-Previous"),
].some((got) => matches(expected, got));
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// enqueue event, then acknowledge fast
res.sendStatus(200);
},
);
A verificação de comprimento não é decoração. timingSafeEqual lança erro em buffers de comprimentos diferentes em vez de retornar falso. Se você usa o SDK TypeScript do guia rápido de API e SDKs, webhooks.verify() em @elido/sdk faz o mesmo HMAC e a mesma comparação segura contra temporização. Passe { maxSkewSec: 300 } explicitamente, porém: sem essa opção, ele não verifica a idade do carimbo de data e hora.
Verificação de webhook HMAC SHA256 em Python e Go
A biblioteca padrão do Python dá conta. Com FastAPI, await request.body() retorna os bytes brutos; no Flask, chame request.get_data() antes que qualquer coisa toque em request.json.
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, HTTPException, Request
SECRET = os.environ["ELIDO_WEBHOOK_SECRET"].strip().encode()
TOLERANCE = 300
app = FastAPI()
def verify(raw: bytes, ts: str, candidates: list) -> bool:
if not ts.isdigit() or abs(time.time() - int(ts)) > TOLERANCE:
return False
digest = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
expected = "v1=" + digest
return any(c and hmac.compare_digest(c, expected) for c in candidates)
@app.post("/webhooks/elido")
async def elido_webhook(request: Request):
raw = await request.body()
h = request.headers
sigs = [h.get("x-elido-signature"), h.get("x-elido-signature-previous")]
if not verify(raw, h.get("x-webhook-timestamp", ""), sigs):
raise HTTPException(status_code=401, detail="bad signature")
event = json.loads(raw)
# enqueue event
return {"ok": True}
Em Go, leia o corpo uma vez, limite seu tamanho e use hmac.Equal de crypto/hmac, que compara em tempo constante. Eu monto a mensagem a partir da string do cabeçalho exatamente como recebida, em vez de reformatar o inteiro analisado.
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"time"
)
const toleranceSec = 300
// Verify returns the raw body when the request carries a valid Elido signature.
func Verify(w http.ResponseWriter, r *http.Request, secret []byte) ([]byte, bool) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
return nil, false
}
tsHeader := r.Header.Get("X-Webhook-Timestamp")
ts, err := strconv.ParseInt(tsHeader, 10, 64)
if err != nil {
return nil, false
}
if age := time.Now().Unix() - ts; age > toleranceSec || age < -toleranceSec {
return nil, false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(tsHeader + "."))
mac.Write(body)
expected := []byte("v1=" + hex.EncodeToString(mac.Sum(nil)))
for _, name := range []string{"X-Elido-Signature", "X-Elido-Signature-Previous"} {
if got := r.Header.Get(name); got != "" && hmac.Equal([]byte(got), expected) {
return body, true
}
}
return nil, false
}
As três versões respondem 401 quando a assinatura é inválida e só analisam o JSON depois que a verificação passa. A Elido conta qualquer resposta que não seja 2xx como uma tentativa com falha, então, se o seu verificador estiver errado, entregas genuínas se acumulam como 401s no registro de entregas do endpoint. Esse é o primeiro lugar para olhar depois de uma implantação.
Quer ver isso com tráfego real? Crie um endpoint em um espaço de trabalho pela página do recurso de webhooks e aponte-o para um túnel local; o registro de entregas mostra o código de status que o seu verificador retornou para cada tentativa.
Verifique a assinatura de webhook dentro de um nó Code do n8n
O n8n consegue fazer a mesma verificação sem serviço extra. Ative a opção de corpo bruto no nó Webhook, que armazena a solicitação intacta como dados binários, e depois adicione um nó Code logo após ele. O módulo crypto embutido é permitido no nó Code do n8n, então isto roda como está:
const crypto = require("crypto");
const h = $input.first().json.headers;
const raw = await this.helpers.getBinaryDataBuffer(0, "data");
const ts = h["x-webhook-timestamp"] ?? "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
throw new Error("stale delivery");
}
const expected = Buffer.from(
"v1=" +
crypto
.createHmac("sha256", $env.ELIDO_WEBHOOK_SECRET)
.update(`${ts}.`)
.update(raw)
.digest("hex"),
);
const ok = ["x-elido-signature", "x-elido-signature-previous"].some((name) => {
const got = Buffer.from(h[name] ?? "");
return (
got.length === expected.length && crypto.timingSafeEqual(got, expected)
);
});
if (!ok) throw new Error("bad signature");
return [{ json: JSON.parse(raw.toString("utf8")) }];
Um erro lançado interrompe a execução, então nada adiante roda em um evento falsificado. Uma ressalva para auto-hospedagem: se a sua instância define N8N_BLOCK_ENV_ACCESS_IN_NODE=true, o nó Code não consegue ler $env, o segredo volta vazio e toda entrega falha na verificação. O guia de n8n auto-hospedado cobre a parte de proxy reverso, e o post sobre encurtador de URL com n8n mostra o que criar quando os eventos chegarem.
Janelas contra repetição e rotação de segredo
Uma assinatura válida prova quem enviou uma solicitação, não quando. Alguém que capture uma entrega assinada, em uma linha de log ou em um proxy mal configurado, poderia enviá-la novamente uma semana depois, e o HMAC ainda corresponderia. A verificação do carimbo de data e hora fecha essa brecha, e essa tarefa é sua: o worker de entrega da Elido assina o carimbo de data e hora, mas não aplica nenhuma janela do seu lado. Eu uso 300 segundos. Mantenha o relógio do receptor sincronizado com NTP, já que um servidor com alguns minutos de desvio começará a rejeitar tráfego legítimo.
Novas tentativas não esbarram na janela. Cada tentativa recebe um X-Webhook-Timestamp novo e uma nova assinatura, enquanto X-Webhook-Delivery permanece igual. Essa separação oferece as duas defesas: o carimbo de data e hora limita por quanto tempo uma solicitação capturada continua utilizável, e um índice único no ID de entrega impede que uma nova tentativa legítima seja processada duas vezes. O post sobre limites de taxa e idempotência cobre o mesmo padrão no lado da API de entrada.
A rotação funciona por POST /v1/workspaces/{workspace_id}/webhooks/{id}/rotate-secret, ou pelo botão de rotação na página do endpoint. A resposta contém o novo segredo uma vez, além de grace_window_days (7) e previous_expires_at. Durante esses sete dias, cada entrega carrega duas assinaturas:
X-Elido-Signature, feita com o novo segredoX-Elido-Signature-Previous, feita com o antigo
É por isso que todos os exemplos acima verificam os dois cabeçalhos contra o único segredo que têm. Um receptor que ainda roda com o segredo antigo corresponde ao segundo cabeçalho; depois que você implanta o novo, ele corresponde ao primeiro. Nada falha nesse intervalo. Trate o cabeçalho da chave anterior como uma ponte, não como uma garantia, e implante o novo segredo logo no começo dessa semana.
Por que a verificação de assinatura de webhook falha
Quando ajudo alguém a depurar isso, a lista curta é quase sempre a mesma. Percorra em ordem:
- JSON serializado novamente.
JSON.stringify(req.body)oujson.dumps(payload)produz bytes diferentes dos que o remetente usou no hash: ordem das chaves, espaçamento, barras escapadas, escapes unicode. Gere o hash do corpo bruto. Se o seu framework já o analisou, corrija a ordem do middleware em vez de tentar reconstruir a string. - Bytes de chave errados. A Elido usa a string
whsec_...inteira como chave HMAC. Remover o prefixo, decodificar o restante de hexadecimal ou decodificar de base64 (o que bibliotecas de Standard Webhooks fazem) dá a você uma chave diferente. Uma quebra de linha final vinda deechopara um arquivo de segredos faz o mesmo, por isso o exemplo em Python chama.strip(). - Codificação incompatível. Compare
v1=mais hexadecimal em minúsculas com o cabeçalho. Um resumo em base64, uma string hexadecimal em maiúsculas ou um prefixo ausente nunca correspondem. - Carimbo de data e hora errado. Use a string do cabeçalho
X-Webhook-Timestamp, não o campotimestampdo corpo, e não um número que você analisou e reformatou.
Dois pontos menores: comparar com == funciona, mas vaza informação pelo tempo de execução, então use a função de tempo constante que a sua linguagem fornece; e um proxy que descompacta ou recodifica corpos também quebrará a verificação, embora isso seja raro para POSTs JSON simples.
Quando os dois lados ainda discordarem, registre o carimbo de data e hora, o comprimento do corpo e os primeiros caracteres hexadecimais do seu resumo, depois rode a linha com openssl mostrada antes com as mesmas entradas. O lado que corresponder ao openssl é o correto. Para ver onde a assinatura se encaixa entre os outros controles que vale verificar em qualquer provedor, consulte a lista de verificação de segurança para encurtador de URL.
Leia o artigo principal: webhooks para eventos de link.
Relacionados no blog
- Webhooks para eventos de link - tipos de evento, envelope da carga útil e a política de novas tentativas.
- Webhooks vs polling para rastreamento de cliques - quando push supera pull, e quando não supera.
- Automação de links auto-hospedada com n8n - proxy reverso, modo de fila e verificações de assinatura em uma só pilha.
- API de encurtador de URL: limites de taxa, novas tentativas, idempotência - os padrões de desduplicação vistos pela outra direção.
- Lista de verificação de segurança para encurtador de URL - nove controles para verificar em qualquer provedor.
Perguntas frequentes
Como verifico uma assinatura de webhook?
Recalcule o HMAC exatamente sobre o que o remetente assinou, usando o segredo compartilhado, e compare o resultado com o cabeçalho de assinatura em tempo constante. Para a Elido, isso significa HMAC-SHA256 sobre o valor de X-Webhook-Timestamp, um ponto e o corpo bruto, codificado em hexadecimal com o prefixo v1=. Rejeite a solicitação se nada corresponder ou se o carimbo de data e hora estiver antigo.
Por que a verificação da minha assinatura de webhook continua falhando?
Quase sempre porque você gerou o hash de bytes diferentes dos que o remetente usou. Um analisador de corpo JSON rodou antes e você serializou o objeto de novo, ou decodificou o segredo, ou comparou hexadecimal com base64. Gere o hash dos bytes brutos da solicitação, use a string do segredo exatamente como foi emitida e registre os dois valores lado a lado.
O que é HMAC em um webhook?
HMAC é um hash com chave: o remetente mistura um segredo compartilhado com você em um hash SHA-256 da mensagem. Só quem tem o segredo consegue produzir um valor correspondente, então uma assinatura válida prova que a solicitação veio do remetente e que o corpo não foi alterado no caminho.
Como evito ataques de repetição em webhooks?
Assine o carimbo de data e hora junto com o corpo e rejeite qualquer solicitação cujo carimbo seja mais antigo que alguns minutos; cinco minutos é uma janela comum. Depois, armazene o ID de entrega e ignore IDs que você já processou. A Elido assina o carimbo de data e hora, mas deixa a verificação de atualidade para o seu receptor.
Devo usar hexadecimal ou base64 para uma assinatura de webhook HMAC-SHA256?
Use o que o remetente documentar, porque as duas codificações do mesmo resumo nunca serão iguais na comparação. A Elido envia hexadecimal em minúsculas depois de um prefixo v1=. A Shopify e a especificação Standard Webhooks usam base64, e o GitHub usa hexadecimal depois de sha256=. Codifique seu resumo da mesma forma antes de comparar.
Como faço a rotação de um segredo de webhook sem perder eventos?
Use um remetente que assine com as duas chaves por um tempo. Depois que você rotaciona o segredo de um endpoint da Elido, cada entrega traz X-Elido-Signature com a nova chave e X-Elido-Signature-Previous com a antiga por sete dias. Aceite qualquer um dos cabeçalhos, implante o novo segredo, e o antigo expira sozinho.
Experimente Elido
Cole uma URL, obtenha um link curto
Sem cadastro. O link vive 30 dias. Cadastre-se para mantê-lo para sempre.
Grátis, sem necessidade de registo · 2 por dia