Pular para o conteúdo principal

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
ChaveDescrição
tTimestamp Unix em segundos, definido quando esta tentativa foi assinada
v1HMAC-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

  1. Extraia t e v1 do cabeçalho.
  2. Monte o payload assinado: os bytes de t, depois ., depois os bytes brutos do corpo da requisição.
  3. Calcule HMAC-SHA256(secret, signedPayload) e codifique em hexadecimal.
  4. Compare com v1 em tempo constante, depois de verificar que os dois valores têm o mesmo comprimento.
  5. Rejeite se |agora - t| > 300 segundos.

Todos os cinco passos precisam passar. Caso contrário, responda 401.

Use o corpo bruto, não o JSON já parseado

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ão express.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:

  1. Como está escrito, seu handler deve retornar 2xx e aplicar a aprovação. Se ele retorna 2xx sem fazer nada, você está usando o campo errado no switch.
  2. Altere um caractere de SECRET. Você deve receber 401.
  3. Substitua a assinatura por v1=ab. Você ainda deve receber 401 — e não um 500. Um 500 aqui 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

ErroSintomaCorreção
Usar o JSON parseado em vez dos bytes brutosA assinatura nunca bateexpress.raw() / request.get_data() / php://input
timingSafeEqual sem checagem de comprimento500 e um RangeError não tratado com entrada malformadaCompare os comprimentos primeiro, retorne false
Comparação direta de stringsAtaque de temporizaçãocrypto.timingSafeEqual / hmac.compare_digest / hash_equals
Sem checagem de timestampRequisições capturadas reenviáveis para sempreRejeite se |agora - t| > 300
Ampliar a tolerância para cobrir as reentregasProteção contra replay mais fraca, sem benefícioToda tentativa é reassinada; mantenha 300
Aparar ou reformatar o corpoAssinatura não confereNão transforme o corpo antes de calcular o hash
Segredo erradoTudo falhaO 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:

  1. Gere o novo segredo: openssl rand -hex 32.
  2. Publique sua aplicação de modo que ela aceite tanto o segredo antigo quanto o novo (veja abaixo).
  3. Salve o novo segredo em Settings → Webhook.
  4. Confirme que as entregas estão validando com o novo segredo.
  5. 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