Pular para o conteúdo principal

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:

KeyDescricao
tUnix timestamp (segundos) quando enviamos o webhook
v1HMAC-SHA256 hex-encoded de <timestamp>.<raw_body> usando seu webhook secret

O algoritmo

Para verificar:

  1. Parse t e v1 do header
  2. Concatene: signedPayload = timestamp + "." + raw_request_body
  3. Compute: expectedV1 = HMAC_SHA256(seuSecret, signedPayload).toHex()
  4. Compare expectedV1 com v1 usando comparacao constant-time
  5. Verifique |now - timestamp| < 300 segundos (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

ErroSintomaFix
Usar JSON parseado em vez de bytes rawA assinatura nunca bateUse express.raw() / request.get_data() / php://input
Comparacao direta de strings em vez de constant-timeVulnerabilidade de timing attackUse crypto.timingSafeEqual / hmac.compare_digest / hash_equals
Falta protecao replayRequests capturadas podem ser repetidas para sempreRejeite se `
Usar o secret erradoTodas as assinaturas falhamCada tenant tem seu proprio secret — verifique o ativo no dashboard
Trimar whitespace do bodyMismatch de assinaturaNao transforme o body antes de computar HMAC

Rotacionando o webhook secret

Para rotacionar sem downtime:

  1. No dashboard, clique em Rotate sobre seu webhook secret
  2. Tanto o secret antigo como o novo funcionam por 15 minutos
  3. Atualize sua variavel de ambiente ao secret novo
  4. Deploy
  5. 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