Verificación de firma
Todo webhook lleva un header Veridia-Signature. Verificalo antes de confiar en el cuerpo. Tu endpoint es una URL en la internet pública que le otorga cuentas a la gente; sin verificación de firma, cualquiera que la descubra puede aprobarse a sí mismo.
El header
Veridia-Signature: t=1753142348,v1=4f8a3b9c01ee5d2f3b4a8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f
| Clave | Descripción |
|---|---|
t | Timestamp Unix en segundos, fijado cuando se firmó este intento |
v1 | HMAC-SHA256 codificado en hex de <t>.<raw_body>, con tu secreto de webhook como clave |
El timestamp vive adentro de este header. No hay un X-Veridia-Timestamp aparte, y el header no lleva prefijo X- — es Veridia-Signature.
El algoritmo
- Parseá
tyv1desde el header. - Armá el payload firmado: los bytes de
t, después., después los bytes crudos del cuerpo de la request. - Calculá
HMAC-SHA256(secret, signedPayload)y codificalo en hex. - Compará contra
v1en tiempo constante, después de chequear que los dos valores tengan la misma longitud. - Rechazá si
|now - t| > 300segundos.
Los cinco pasos tienen que pasar. Si no, respondé 401.
Calculá el HMAC sobre los bytes exactos que recibiste. Parsear el JSON y re-serializarlo produce una secuencia de bytes distinta — otro orden de claves, otros espacios, otro formato de números — y el digest no va a coincidir. Esta es de lejos la causa más común de "la firma nunca valida".
- Express:
express.raw({ type: 'application/json' }), noexpress.json() - Flask:
request.get_data() - FastAPI:
await request.body() - PHP:
file_get_contents('php://input')
Por qué alcanza con 300 segundos
Los reintentos se extienden por ~12,6 minutos, lo que sugiere que la tolerancia debería ser más amplia. No debería.
El despachador vuelve a firmar en cada intento, así que el sexto reintento llega con un t de segundos de antigüedad, no de doce minutos. Una ventana de 300 segundos nunca rechaza un reintento tardío legítimo. Ampliarla solo extiende cuánto tiempo una request capturada sigue siendo reproducible. Dejala en 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, no json — necesitamos los bytes exactos
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) {
// Parsear "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;
// Protección 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');
// Comparar longitudes PRIMERO: timingSafeEqual lanza un RangeError si los
// buffers tienen longitudes distintas. Sin esto, una request que trae
// `v1=ab` produce una excepción no capturada y un 500 en vez de un 401 —
// una denegación de servicio de una línea contra tu 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 maneja longitudes desiguales de forma segura,
# a diferencia del timingSafeEqual de 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 crudos, no 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 es seguro ante longitudes distintas y de tiempo 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]);
Ojo con el nombre del header en PHP: Veridia-Signature se convierte en $_SERVER['HTTP_VERIDIA_SIGNATURE'].
Probalo a mano con curl
Firmá un cuerpo vos mismo y mandalo a tu handler. El cuerpo de abajo tiene la forma real del payload, así que esto ejercita tanto tu routing como tu chequeo de firma — un cuerpo de prueba con nombres de campo equivocados dejaría que un handler roto parezca sano.
#!/bin/bash
# replay.sh — postea un webhook con forma de Veridia y una firma fresca
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"
Tres chequeos que vale la pena correr una vez:
- Tal como está escrito, tu handler debería devolver
2xxy aplicar la aprobación. Si devuelve2xxsin hacer nada, estás haciendo el switch sobre el campo equivocado. - Cambiá un carácter de
SECRET. Deberías obtener401. - Reemplazá la firma por
v1=ab. Deberías seguir obteniendo401— no un500. Un500acá significa que a tu comparación le falta el chequeo de longitud.
Notá que esto postea a http://localhost, lo cual funciona porque el que envía sos vos. Las entregas reales desde Veridia requieren una URL https://; para esas, usá un túnel.
Errores comunes
| Error | Síntoma | Arreglo |
|---|---|---|
| Usar el JSON parseado en vez de los bytes crudos | La firma nunca coincide | express.raw() / request.get_data() / php://input |
timingSafeEqual sin chequeo de longitud | 500 y un RangeError no capturado ante entrada malformada | Comparar longitudes primero, devolver false |
| Comparación directa de strings | Ataque de temporización | crypto.timingSafeEqual / hmac.compare_digest / hash_equals |
| Sin chequeo de timestamp | Requests capturadas reproducibles para siempre | Rechazar si |now - t| > 300 |
| Ampliar la tolerancia para cubrir los reintentos | Protección contra replay más débil, sin beneficio | Cada intento se vuelve a firmar; dejá 300 |
| Recortar o reformatear el cuerpo | Firma que no coincide | No transformes el cuerpo antes de hashear |
| Secreto equivocado | Falla todo | El secreto es el que vos definiste en Settings → Webhook |
Rotar el secreto del webhook
No hay período de gracia con doble secreto. Guardar un secreto nuevo en Settings → Webhook reemplaza al viejo de inmediato, y las entregas firmadas con el secreto viejo se cortan en el momento en que guardás.
Ordená los pasos para que tu aplicación esté lista antes del cambio:
- Generá el secreto nuevo:
openssl rand -hex 32. - Desplegá tu aplicación de modo que acepte ambos secretos, el viejo y el nuevo (ver abajo).
- Guardá el secreto nuevo en Settings → Webhook.
- Confirmá que las entregas estén validando contra el secreto nuevo.
- Desplegá otra vez, sacando el secreto viejo.
Los pasos 2 y 5 son los que hacen que esta sea una rotación sin pérdidas. La ventana de gracia te toca crearla a vos en tu propio código — Veridia no provee una, y una rotación hecha como "guardo en el panel y después despliego" hace fallar todas las firmas durante lo que dure tu deploy. Esos fallos son 401, que el despachador trata como permanentes: los eventos no se reintentan, y cada veredicto producido durante esa ventana hay que volver a encolarlo a mano o reconciliarlo por la 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, // sacar después del paso 5
]);
El secreto es de solo escritura en el panel: nunca se te muestra de vuelta. Si lo perdés, definí uno nuevo y seguí la misma secuencia.
Qué sigue
- Tipos de evento — el contrato del payload
- Ejemplos — implementaciones completas de handlers
- Reintentos — garantías de entrega y recuperación
- Webhooks — volver al índice de la sección