Verificação de assinatura
Todo webhook carrega um cabeçalho Veridia-Signature. Verifique-o antes de confiar no corpo. Seu endpoint é uma URL na internet pública que concede contas a pessoas; sem verificação de assinatura, qualquer um que a descubra pode aprovar a si mesmo.
O cabeçalho
Veridia-Signature: t=1753142348,v1=4f8a3b9c01ee5d2f3b4a8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f
| Chave | Descrição |
|---|---|
t | Timestamp Unix em segundos, definido quando esta tentativa foi assinada |
v1 | HMAC-SHA256 de <t>.<raw_body> codificado em hexadecimal, com o seu segredo de webhook como chave |
O timestamp vive dentro deste cabeçalho. Não existe um X-Veridia-Timestamp separado, e o cabeçalho não tem prefixo X- — ele é Veridia-Signature.
O algoritmo
- Extraia
tev1do cabeçalho. - Monte o payload assinado: os bytes de
t, depois., depois os bytes brutos do corpo da requisição. - Calcule
HMAC-SHA256(secret, signedPayload)e codifique em hexadecimal. - Compare com
v1em tempo constante, depois de verificar que os dois valores têm o mesmo comprimento. - Rejeite se
|agora - t| > 300segundos.
Todos os cinco passos precisam passar. Caso contrário, responda 401.
Calcule o HMAC sobre os bytes exatos que você recebeu. Fazer o parse do JSON e re-serializá-lo produz uma sequência de bytes diferente — outra ordem de chaves, outros espaços em branco, outra formatação de números — e o digest não vai bater. Esta é a causa mais comum de "a assinatura nunca valida".
- Express:
express.raw({ type: 'application/json' }), nãoexpress.json() - Flask:
request.get_data() - FastAPI:
await request.body() - PHP:
file_get_contents('php://input')
Por que 300 segundos são suficientes
As reentregas se estendem por ~12,6 minutos, o que sugere que a tolerância deveria ser maior. Não deveria.
O despachante reassina a cada tentativa, então a sexta reentrega chega com um t de poucos segundos, não de doze minutos. Uma janela de 300 segundos nunca rejeita uma reentrega tardia legítima. Ampliá-la só estende por quanto tempo uma requisição capturada continua reenviável. Deixe em 300.
Node.js / Express
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const VERIDIA_SECRET = process.env.VERIDIA_WEBHOOK_SECRET;
// raw, não json — precisamos dos bytes exatos
app.post(
'/webhooks/veridia',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sigHeader = req.header('Veridia-Signature') || '';
const rawBody = req.body; // Buffer
if (!verifyVeridiaSignature(sigHeader, rawBody, VERIDIA_SECRET)) {
return res.status(401).send('Invalid signature');
}
res.status(200).send('ok');
const payload = JSON.parse(rawBody.toString('utf8'));
await processWebhook(payload);
}
);
function verifyVeridiaSignature(header, rawBody, secret) {
// Parse de "t=...,v1=..."
const parts = {};
for (const piece of header.split(',')) {
const idx = piece.indexOf('=');
if (idx > 0) parts[piece.slice(0, idx).trim()] = piece.slice(idx + 1).trim();
}
const timestamp = parseInt(parts.t, 10);
const receivedSig = parts.v1;
if (!timestamp || !receivedSig) return false;
// Proteção contra replay
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) return false;
const signedPayload = Buffer.concat([
Buffer.from(`${timestamp}.`, 'utf8'),
rawBody,
]);
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Compare os comprimentos PRIMEIRO: timingSafeEqual lança um RangeError
// quando os buffers têm comprimentos diferentes. Sem isto, uma requisição
// carregando `v1=ab` produz uma exceção não tratada e um 500 em vez de um
// 401 — uma negação de serviço de uma linha contra o seu endpoint de webhook.
const expectedBuf = Buffer.from(expectedSig, 'utf8');
const receivedBuf = Buffer.from(receivedSig, 'utf8');
if (expectedBuf.length !== receivedBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}
Python / Flask
import hmac
import hashlib
import time
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
VERIDIA_SECRET = os.environ["VERIDIA_WEBHOOK_SECRET"].encode()
def verify_veridia_signature(header: str, raw_body: bytes, secret: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp_str = parts.get("t")
received_sig = parts.get("v1")
if not timestamp_str or not received_sig:
return False
try:
timestamp = int(timestamp_str)
except ValueError:
return False
if abs(time.time() - timestamp) > 300:
return False
signed_payload = f"{timestamp}.".encode() + raw_body
expected_sig = hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()
# compare_digest lida com comprimentos diferentes de forma segura,
# ao contrário do timingSafeEqual do Node.
return hmac.compare_digest(expected_sig, received_sig)
@app.route("/webhooks/veridia", methods=["POST"])
def veridia_webhook():
sig_header = request.headers.get("Veridia-Signature", "")
raw_body = request.get_data() # bytes brutos, não JSON parseado
if not verify_veridia_signature(sig_header, raw_body, VERIDIA_SECRET):
return jsonify({"error": "Invalid signature"}), 401
payload = request.get_json()
process_webhook(payload)
return jsonify({"ok": True}), 200
Python / FastAPI
import hmac
import hashlib
import time
import os
import json
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
VERIDIA_SECRET = os.environ["VERIDIA_WEBHOOK_SECRET"].encode()
def verify_veridia_signature(header: str, raw_body: bytes, secret: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp_str = parts.get("t")
received_sig = parts.get("v1")
if not timestamp_str or not received_sig:
return False
try:
timestamp = int(timestamp_str)
except ValueError:
return False
if abs(time.time() - timestamp) > 300:
return False
signed_payload = f"{timestamp}.".encode() + raw_body
expected_sig = hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected_sig, received_sig)
@app.post("/webhooks/veridia")
async def veridia_webhook(request: Request):
sig_header = request.headers.get("veridia-signature", "")
raw_body = await request.body()
if not verify_veridia_signature(sig_header, raw_body, VERIDIA_SECRET):
raise HTTPException(status_code=401, detail="Invalid signature")
payload = json.loads(raw_body)
process_webhook(payload)
return {"ok": True}
PHP
<?php
function verifyVeridiaSignature(string $header, string $rawBody, string $secret): bool {
$parts = [];
foreach (explode(',', $header) as $p) {
$kv = explode('=', $p, 2);
if (count($kv) === 2) {
$parts[trim($kv[0])] = trim($kv[1]);
}
}
$timestamp = (int)($parts['t'] ?? 0);
$receivedSig = $parts['v1'] ?? '';
if ($timestamp === 0 || $receivedSig === '') {
return false;
}
if (abs(time() - $timestamp) > 300) {
return false;
}
$signedPayload = $timestamp . '.' . $rawBody;
$expectedSig = hash_hmac('sha256', $signedPayload, $secret);
// hash_equals é seguro quanto a comprimento e opera em tempo constante.
return hash_equals($expectedSig, $receivedSig);
}
// Handler
$secret = $_ENV['VERIDIA_WEBHOOK_SECRET'];
$rawBody = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_VERIDIA_SIGNATURE'] ?? '';
if (!verifyVeridiaSignature($sigHeader, $rawBody, $secret)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$payload = json_decode($rawBody, true);
processWebhook($payload);
http_response_code(200);
echo json_encode(['ok' => true]);
Repare no nome do cabeçalho em PHP: Veridia-Signature vira $_SERVER['HTTP_VERIDIA_SIGNATURE'].
Teste manualmente com curl
Assine um corpo você mesmo e faça POST no seu handler. O corpo abaixo tem o formato real do payload, então isto exercita também o seu roteamento, além da checagem de assinatura — um corpo de teste com os nomes de campo errados deixaria um handler quebrado parecer saudável.
#!/bin/bash
# replay.sh — faz POST de um webhook no formato da Veridia com uma assinatura nova
SECRET="$VERIDIA_WEBHOOK_SECRET"
URL="http://localhost:3000/webhooks/veridia"
BODY='{"id":"evt_00000000000000000000000000000001","type":"verification.approved","createdAt":1753142348,"tenantId":"tn_default_demo","verificationId":"vf_TESTREPLAY0000001","verdict":"approved","confidence":93.1,"userRef":"customer-12345","scores":{"ocr_confidence":78.0,"face_match":96.2,"liveness":91.5,"doc_quality":85.0,"mrz_valid":100.0,"name_match":88.0},"flags":[{"level":"ok","text":"auto_approved_all_checks_passed"}],"fieldsExtracted":{"full_name":"TEST USER","document_number":"0000000","date_of_birth":"1990-01-01","nationality":"PRY","document_type":"dni"},"latencyMs":3184}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')
curl -sS -X POST "$URL" \
-H "Content-Type: application/json" \
-H "Veridia-Signature: t=${TS},v1=${SIG}" \
-H "Veridia-Event: verification.approved" \
-H "Veridia-Event-Id: evt_00000000000000000000000000000001" \
--data-raw "$BODY"
Três checagens que vale rodar uma vez:
- Como está escrito, seu handler deve retornar
2xxe aplicar a aprovação. Se ele retorna2xxsem fazer nada, você está usando o campo errado no switch. - Altere um caractere de
SECRET. Você deve receber401. - Substitua a assinatura por
v1=ab. Você ainda deve receber401— e não um500. Um500aqui significa que falta a checagem de comprimento na sua comparação.
Note que isto faz POST em http://localhost, o que funciona porque você é o remetente. Entregas reais da Veridia exigem uma URL https://; para essas, use um túnel.
Erros comuns
| Erro | Sintoma | Correção |
|---|---|---|
| Usar o JSON parseado em vez dos bytes brutos | A assinatura nunca bate | express.raw() / request.get_data() / php://input |
timingSafeEqual sem checagem de comprimento | 500 e um RangeError não tratado com entrada malformada | Compare os comprimentos primeiro, retorne false |
| Comparação direta de strings | Ataque de temporização | crypto.timingSafeEqual / hmac.compare_digest / hash_equals |
| Sem checagem de timestamp | Requisições capturadas reenviáveis para sempre | Rejeite se |agora - t| > 300 |
| Ampliar a tolerância para cobrir as reentregas | Proteção contra replay mais fraca, sem benefício | Toda tentativa é reassinada; mantenha 300 |
| Aparar ou reformatar o corpo | Assinatura não confere | Não transforme o corpo antes de calcular o hash |
| Segredo errado | Tudo falha | O segredo é aquele que você definiu em Settings → Webhook |
Rotacionando o segredo do webhook
Não existe período de tolerância com dois segredos. Salvar um novo segredo em Settings → Webhook substitui o antigo imediatamente, e as entregas assinadas com o segredo antigo param no instante em que você salva.
Ordene os passos de forma que sua aplicação esteja pronta antes da troca:
- Gere o novo segredo:
openssl rand -hex 32. - Publique sua aplicação de modo que ela aceite tanto o segredo antigo quanto o novo (veja abaixo).
- Salve o novo segredo em Settings → Webhook.
- Confirme que as entregas estão validando com o novo segredo.
- Publique de novo, removendo o segredo antigo.
Os passos 2 e 5 são o que torna esta uma rotação sem perdas. A janela de tolerância é sua para criar no seu próprio código — a Veridia não fornece uma, e uma rotação feita como "salvar no painel e depois publicar" faz todas as assinaturas falharem durante o tempo do seu deploy. Essas falhas são 401s, que o despachante trata como permanentes: os eventos não são reentregues, e todo veredicto produzido nessa janela precisa ser recolocado na fila manualmente ou reconciliado pela API.
function verifyWithAnySecret(header, rawBody, secrets) {
return secrets
.filter(Boolean)
.some(secret => verifyVeridiaSignature(header, rawBody, secret));
}
const valid = verifyWithAnySecret(sigHeader, rawBody, [
process.env.VERIDIA_WEBHOOK_SECRET,
process.env.VERIDIA_WEBHOOK_SECRET_OLD, // remover após o passo 5
]);
O segredo é somente escrita no painel: ele nunca é exibido de volta para você. Se você o perder, defina um novo e siga a mesma sequência.
O que vem a seguir
- Tipos de evento — o contrato do payload
- Exemplos — implementações completas de handlers
- Reentregas — garantias de entrega e recuperação
- Webhooks — de volta ao índice da seção