Saltar al contenido principal

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
ClaveDescripción
tTimestamp Unix en segundos, fijado cuando se firmó este intento
v1HMAC-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

  1. Parseá t y v1 desde el header.
  2. Armá el payload firmado: los bytes de t, después ., después los bytes crudos del cuerpo de la request.
  3. Calculá HMAC-SHA256(secret, signedPayload) y codificalo en hex.
  4. Compará contra v1 en tiempo constante, después de chequear que los dos valores tengan la misma longitud.
  5. Rechazá si |now - t| > 300 segundos.

Los cinco pasos tienen que pasar. Si no, respondé 401.

Usá el cuerpo crudo, no el JSON parseado

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' }), no express.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:

  1. Tal como está escrito, tu handler debería devolver 2xx y aplicar la aprobación. Si devuelve 2xx sin hacer nada, estás haciendo el switch sobre el campo equivocado.
  2. Cambiá un carácter de SECRET. Deberías obtener 401.
  3. Reemplazá la firma por v1=ab. Deberías seguir obteniendo 401 — no un 500. Un 500 acá 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

ErrorSíntomaArreglo
Usar el JSON parseado en vez de los bytes crudosLa firma nunca coincideexpress.raw() / request.get_data() / php://input
timingSafeEqual sin chequeo de longitud500 y un RangeError no capturado ante entrada malformadaComparar longitudes primero, devolver false
Comparación directa de stringsAtaque de temporizacióncrypto.timingSafeEqual / hmac.compare_digest / hash_equals
Sin chequeo de timestampRequests capturadas reproducibles para siempreRechazar si |now - t| > 300
Ampliar la tolerancia para cubrir los reintentosProtección contra replay más débil, sin beneficioCada intento se vuelve a firmar; dejá 300
Recortar o reformatear el cuerpoFirma que no coincideNo transformes el cuerpo antes de hashear
Secreto equivocadoFalla todoEl 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:

  1. Generá el secreto nuevo: openssl rand -hex 32.
  2. Desplegá tu aplicación de modo que acepte ambos secretos, el viejo y el nuevo (ver abajo).
  3. Guardá el secreto nuevo en Settings → Webhook.
  4. Confirmá que las entregas estén validando contra el secreto nuevo.
  5. 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