▸ API · Webhooks

Verificar a assinatura

HMAC SHA-256 sobre t.corpo, janela de 5 minutos e código pronto em Node e Python.

atualizado em 2 set 2026versão v1

Por que verificar

Seu endpoint de webhook é uma URL pública que aceita POST sem login. Sem verificar, qualquer um que descubra o endereço injeta um scan.completed com risco 0 e apaga do seu painel a brecha que a ronda de verdade encontrou. A assinatura resolve isso porque depende de um segredo que só você e a Guarita conhecem.

Toda entrega traz x-guarita-signature no formato t=<unix>,v1=<hmac hex>. O conteúdo assinado é <t>.<corpo cru>: HMAC SHA-256 com o segredo gwh_ do destino, em hexadecimal.

O timestamp entra na assinatura, não só num cabeçalho ao lado: solto, ele seria trocável, e alguém reenviaria o mesmo corpo com a mesma assinatura válida pra sempre. Rejeite entregas com t a mais de 300 segundos do seu relógio.

uma entrega, como chega
POST /guarita HTTP/1.1
Host: api.suaempresa.com.br
Content-Type: application/json
User-Agent: Guarita-Webhook/1
x-guarita-signature: t=1756483200,v1=6f2a9c1d4b8e7f30a5c2d1e9b4f6a7c8d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4c4
x-guarita-delivery: dlv_2f0f6a1b-6b4e-4a58-9b2a-1f0f2c3d4e5f

{"id":"evt_9f1c2a54-4c1e-4a0c-9a1f-7b6a2f0d8e33","event":"scan.completed","createdAt":"2026-08-29T14:22:07.481Z","data":{...}}

Os seis passos

  1. Leia o corpo cru, em bytes

    Antes de qualquer parse. JSON.parse seguido de JSON.stringify reordena chaves e muda espaços — os bytes deixam de ser os assinados.

  2. Parseie o cabeçalho

    Separe por vírgula, depois por =. Pegue t (inteiro) e v1 (hex). Falta algum? Rejeite.

  3. Rejeite fora da janela

    Se |agora − t| > 300 segundos, rejeite. É a defesa contra replay.

  4. Calcule o HMAC

    SHA-256 de `${t}.${corpo}` com o segredo do destino. O ponto entre o timestamp e o corpo faz parte do conteúdo.

  5. Compare em tempo constante

    timingSafeEqual em Node, hmac.compare_digest em Python. Nunca ===.

  6. Só então faça o parse

    Assinatura válida? Agora sim o JSON vira objeto. Responda 2xx e processe depois.

Código pronto

A mesma função verificar(header, corpo, segredo) nas duas linguagens, com o handler em volta. O segredo vem de GUARITA_WEBHOOK_SECRET — nunca do código.

verificar.mjs — Node.js + Express
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const TOLERANCIA_S = 300;

/** header = "t=<unix>,v1=<hex>" · corpo = Buffer com os BYTES crus · segredo = gwh_… */
export function verificar(header, corpo, segredo) {
  if (typeof header !== "string") return false;

  // 2. parsear o cabeçalho
  const partes = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
  const t = Number(partes.t);
  const v1 = partes.v1;
  if (!Number.isFinite(t) || typeof v1 !== "string" || !/^[0-9a-f]+$/i.test(v1)) return false;

  // 3. rejeitar fora da janela (replay)
  const agora = Math.floor(Date.now() / 1000);
  if (Math.abs(agora - t) > TOLERANCIA_S) return false;

  // 4. HMAC SHA-256 de "<t>.<corpo cru>"
  const esperado = createHmac("sha256", segredo)
    .update(`${t}.`)
    .update(corpo) // Buffer, nunca o objeto parseado
    .digest();
  const recebido = Buffer.from(v1, "hex");

  // 5. comparar em tempo constante
  return esperado.length === recebido.length && timingSafeEqual(esperado, recebido);
}

const app = express();

// 1. express.raw entrega o corpo como Buffer — antes de qualquer parse.
app.post("/guarita", express.raw({ type: "application/json" }), (req, res) => {
  if (!verificar(req.headers["x-guarita-signature"], req.body, process.env.GUARITA_WEBHOOK_SECRET)) {
    return res.status(401).send(); // 4xx é definitivo: a Guarita não reenvia
  }

  // 6. só agora o JSON.parse
  const evento = JSON.parse(req.body.toString("utf8"));
  res.status(204).send(); // responde antes de processar

  processar(evento).catch((err) => console.error("[guarita] falha ao processar", err));
});

app.listen(3000);

Testar localmente

Não dá pra apontar um destino pra sua máquina (host interno é recusado no cadastro). Pra exercitar o handler antes de ligar o destino de verdade, gere a assinatura na linha de comando e faça o POST você mesmo:

assinar-e-enviar.sh
# Segredo do destino (o gwh_… que a tela mostrou uma vez) e um corpo qualquer
SECRET="gwh_SEU_SEGREDO"
T=$(date +%s)
BODY='{"id":"evt_teste","event":"scan.completed","createdAt":"2026-08-29T14:22:07.481Z","data":{"scan_id":"scan_5e2a91c4","hostname":"app.exemplo.com.br","target":"https://app.exemplo.com.br","risk_score":41,"risk_label":"MODERATE_RISK","critical_count":1,"blocked":false}}'

# HMAC SHA-256 de "<t>.<corpo>", em hex
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')

# A mesma entrega que a Guarita faria — contra o seu handler local
curl -i -X POST http://localhost:3000/guarita \
  -H "content-type: application/json" \
  -H "user-agent: Guarita-Webhook/1" \
  -H "x-guarita-signature: t=$T,v1=$SIG" \
  -H "x-guarita-delivery: dlv_teste" \
  --data-raw "$BODY"

Handler respondeu 204? Troque o corpo por um caractere e repita: agora precisa responder 401. Depois disso, exponha uma URL HTTPS pública (um túnel serve) e cadastre-a em Configurações → Desenvolvedores.

Erros que mais mordem

  • Parsear antes de verificar. O framework já transformou o corpo em objeto; ao serializar de novo, chaves e espaços mudam e a assinatura falha. Use express.raw, request.get_data() ou o equivalente.
  • Comparar com ===. Comparação comum para no primeiro byte diferente, e o tempo de resposta entrega a assinatura aos poucos. Use tempo constante.
  • Ignorar o t. Sem a janela, uma entrega capturada vale pra sempre.
  • Usar o user-agent como prova. Qualquer um manda Guarita-Webhook/1. Só a assinatura prova a origem.
  • Relógio do servidor atrasado. Mais de 300 segundos de diferença e toda entrega legítima cai no passo 3. Sincronize com NTP.
Achou algo errado ou faltando? help@guarita.dev — com o x-request-id, se for sobre uma resposta.