Verificacao de assinatura
Cada webhook do Veridia inclui um header Veridia-Signature. Voce deve verificar esta assinatura antes de confiar no body — caso contrario qualquer um que conheca sua URL poderia forjar eventos.
Esta pagina percorre o algoritmo com codigo copy-paste para os quatro stacks de servidor mais comuns.
O header de assinatura
Veridia-Signature: t=1714604000,v1=4f8a3b9c01ee5d2f3b4a8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f
Dois pares key=value separados por virgula:
| Key | Descricao |
|---|---|
t | Unix timestamp (segundos) quando enviamos o webhook |
v1 | HMAC-SHA256 hex-encoded de <timestamp>.<raw_body> usando seu webhook secret |
O algoritmo
Para verificar:
- Parse
tev1do header - Concatene:
signedPayload = timestamp + "." + raw_request_body - Compute:
expectedV1 = HMAC_SHA256(seuSecret, signedPayload).toHex() - Compare
expectedV1comv1usando comparacao constant-time - Verifique
|now - timestamp| < 300segundos (5 minutos — previne replay attacks)
Se ambas verificacoes passam, a request e autentica. Caso contrario, rejeite com 401.
:::warning Use o body raw, nao o JSON parseado Voce deve computar o HMAC dos bytes raw do request body, exatamente como recebeu. Re-serializar JSON parseado vai produzir uma sequencia de bytes diferente e a assinatura nao vai bater.
No Express, isso significa usar middleware express.raw() — nao express.json().
No Flask, use request.get_data().
No PHP, use file_get_contents('php://input').
:::
Node.js / Express
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const VERIDIA_SECRET = process.env.VERIDIA_WEBHOOK_SECRET;
// IMPORTANTE: use raw, nao 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('Assinatura invalida');
}
// Acknowledge imediatamente
res.status(200).send('ok');
// Agora e seguro parsear e processar
const payload = JSON.parse(rawBody.toString('utf8'));
await processWebhook(payload);
}
);
function verifyVeridiaSignature(header, rawBody, secret) {
// Parse "t=...,v1=..."
const parts = Object.fromEntries(
header.split(',').map(p => {
const [k, v] = p.split('=');
return [k, v];
})
);
const timestamp = parseInt(parts.t, 10);
const receivedSig = parts.v1;
if (!timestamp || !receivedSig) return false;
// Protecao replay: rejeita se timestamp esta a mais de 5 minutos
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) return false;
// Computa assinatura esperada
const signedPayload = Buffer.concat([
Buffer.from(`${timestamp}.`, 'utf8'),
rawBody,
]);
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Comparacao constant-time
return crypto.timingSafeEqual(
Buffer.from(expectedSig, 'hex'),
Buffer.from(receivedSig, 'hex')
);
}
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:
# Parse "t=...,v1=..."
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
# Protecao replay
if abs(time.time() - timestamp) > 300:
return False
# Computa assinatura esperada
signed_payload = f"{timestamp}.".encode() + raw_body
expected_sig = hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()
# Comparacao constant-time
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 raw, nao JSON parseado
if not verify_veridia_signature(sig_header, raw_body, VERIDIA_SECRET):
return jsonify({"error": "Assinatura invalida"}), 401
payload = request.get_json()
# Processa async se necessario
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()
@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="Assinatura invalida")
payload = json.loads(raw_body)
process_webhook(payload)
return {"ok": True}
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)
PHP
<?php
function verifyVeridiaSignature(string $header, string $rawBody, string $secret): bool {
// Parse "t=...,v1=..."
$parts = [];
foreach (explode(',', $header) as $p) {
$kv = explode('=', $p, 2);
if (count($kv) === 2) {
$parts[$kv[0]] = $kv[1];
}
}
$timestamp = (int)($parts['t'] ?? 0);
$receivedSig = $parts['v1'] ?? '';
if ($timestamp === 0 || $receivedSig === '') {
return false;
}
// Protecao replay
if (abs(time() - $timestamp) > 300) {
return false;
}
// Computa assinatura esperada
$signedPayload = $timestamp . '.' . $rawBody;
$expectedSig = hash_hmac('sha256', $signedPayload, $secret);
// Comparacao constant-time
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' => 'Assinatura invalida']);
exit;
}
$payload = json_decode($rawBody, true);
// Processa
processWebhook($payload);
http_response_code(200);
echo json_encode(['ok' => true]);
Teste manualmente com curl
Voce pode simular um webhook localmente para testar sua logica de verificacao:
#!/bin/bash
# replay.sh - Repetir um webhook Veridia com assinatura fresca
SECRET="whsec_seu_test_secret"
URL="http://localhost:3000/webhooks/veridia"
BODY='{"event":"verification.approved","verificationId":"vf_TEST_REPLAY"}'
TS=$(date +%s)
# Computa assinatura exatamente como Veridia faz
SIG=$(printf "%s.%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST "$URL" \
-H "Content-Type: application/json" \
-H "Veridia-Signature: t=${TS},v1=${SIG}" \
-H "Veridia-Event: verification.approved" \
-d "$BODY"
Se seu handler responde 200, sua logica de verificacao esta correta. Mude um byte do SECRET para ver que rejeita com 401.
Erros comuns
| Erro | Sintoma | Fix |
|---|---|---|
| Usar JSON parseado em vez de bytes raw | A assinatura nunca bate | Use express.raw() / request.get_data() / php://input |
| Comparacao direta de strings em vez de constant-time | Vulnerabilidade de timing attack | Use crypto.timingSafeEqual / hmac.compare_digest / hash_equals |
| Falta protecao replay | Requests capturadas podem ser repetidas para sempre | Rejeite se ` |
| Usar o secret errado | Todas as assinaturas falham | Cada tenant tem seu proprio secret — verifique o ativo no dashboard |
| Trimar whitespace do body | Mismatch de assinatura | Nao transforme o body antes de computar HMAC |
Rotacionando o webhook secret
Para rotacionar sem downtime:
- No dashboard, clique em Rotate sobre seu webhook secret
- Tanto o secret antigo como o novo funcionam por 15 minutos
- Atualize sua variavel de ambiente ao secret novo
- Deploy
- Apos 15 minutos, o secret antigo fica permanentemente invalido
Durante a janela de rotacao, seu codigo pode aceitar qualquer um dos dois secrets:
function verifyVeridiaSignatureMulti(header, rawBody, secrets) {
return secrets.some(secret => verifyVeridiaSignature(header, rawBody, secret));
}
// Depois:
const valid = verifyVeridiaSignatureMulti(
sigHeader,
rawBody,
[process.env.VERIDIA_WEBHOOK_SECRET, process.env.VERIDIA_WEBHOOK_SECRET_OLD]
);
Proximos passos
- Tipos de eventos — schemas completos para cada evento
- Exemplos — implementacoes de handler completas
- Webhooks overview — voltar ao overview de webhooks