Saltar al contenido principal

Verificacion de firma

Cada webhook de Veridia incluye un header Veridia-Signature. Tenes que verificar esta firma antes de confiar en el body — de otro modo cualquiera que conozca tu URL podria forjar eventos.

Esta pagina te recorre el algoritmo con codigo copy-paste para los cuatro stacks de servidor mas comunes.

El header de firma

Veridia-Signature: t=1714604000,v1=4f8a3b9c01ee5d2f3b4a8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f

Dos pares key=value separados por coma:

KeyDescripcion
tUnix timestamp (segundos) cuando enviamos el webhook
v1HMAC-SHA256 hex-encoded de <timestamp>.<raw_body> usando tu webhook secret

El algoritmo

Para verificar:

  1. Parsea t y v1 del header
  2. Concatena: signedPayload = timestamp + "." + raw_request_body
  3. Calcula: expectedV1 = HMAC_SHA256(tuSecret, signedPayload).toHex()
  4. Compara expectedV1 con v1 usando comparacion constant-time
  5. Verifica |now - timestamp| < 300 segundos (5 minutos — previene replay attacks)

Si ambas verificaciones pasan, la request es autentica. De otro modo, rechaza con 401.

:::warning Usa el body raw, no el JSON parseado Tenes que calcular el HMAC desde los bytes raw del request body, exactamente como los recibiste. Re-serializar JSON parseado va a producir una secuencia de bytes diferente y la firma no va a matchear.

En Express, esto significa usar middleware express.raw() — no express.json(). En Flask, usa request.get_data(). En PHP, usa 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: usa 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('Firma invalida');
}

// Acknowledge inmediatamente
res.status(200).send('ok');

// Ahora es seguro parsear y procesar
const payload = JSON.parse(rawBody.toString('utf8'));
await processWebhook(payload);
}
);

function verifyVeridiaSignature(header, rawBody, secret) {
// Parsea "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;

// Proteccion replay: rechaza si timestamp esta a mas de 5 minutos
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) return false;

// Calcula firma esperada
const signedPayload = Buffer.concat([
Buffer.from(`${timestamp}.`, 'utf8'),
rawBody,
]);
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');

// Comparacion 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:
# Parsea "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

# Proteccion replay
if abs(time.time() - timestamp) > 300:
return False

# Calcula firma esperada
signed_payload = f"{timestamp}.".encode() + raw_body
expected_sig = hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()

# Comparacion 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, no JSON parseado

if not verify_veridia_signature(sig_header, raw_body, VERIDIA_SECRET):
return jsonify({"error": "Firma invalida"}), 401

payload = request.get_json()

# Procesa async si es necesario
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="Firma 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 {
// Parsea "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;
}

// Proteccion replay
if (abs(time() - $timestamp) > 300) {
return false;
}

// Calcula firma esperada
$signedPayload = $timestamp . '.' . $rawBody;
$expectedSig = hash_hmac('sha256', $signedPayload, $secret);

// Comparacion 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' => 'Firma invalida']);
exit;
}

$payload = json_decode($rawBody, true);

// Procesa
processWebhook($payload);

http_response_code(200);
echo json_encode(['ok' => true]);

Testealo manualmente con curl

Podes simular un webhook localmente para testear tu logica de verificacion:

#!/bin/bash
# replay.sh - Repetir un webhook Veridia con firma fresca

SECRET="whsec_tu_test_secret"
URL="http://localhost:3000/webhooks/veridia"

BODY='{"event":"verification.approved","verificationId":"vf_TEST_REPLAY"}'
TS=$(date +%s)

# Calcula firma exactamente como lo hace Veridia
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"

Si tu handler responde 200, tu logica de verificacion es correcta. Cambia un byte del SECRET para ver que rechaza con 401.

Errores comunes

ErrorSintomaFix
Usar JSON parseado en lugar de bytes rawLa firma nunca matcheaUsa express.raw() / request.get_data() / php://input
Comparacion directa de strings en lugar de constant-timeVulnerabilidad de timing attackUsa crypto.timingSafeEqual / hmac.compare_digest / hash_equals
Falta proteccion replayRequests capturadas pueden ser repetidas para siempreRechaza si `
Usar el secret equivocadoTodas las firmas fallanCada tenant tiene su propio secret — verifica el activo en el dashboard
Trimear whitespace del bodyMismatch de firmaNo transformes el body antes de calcular HMAC

Rotando el webhook secret

Para rotar sin downtime:

  1. En el dashboard, hace click en Rotate sobre tu webhook secret
  2. Tanto el secret viejo como el nuevo funcionan por 15 minutos
  3. Actualiza tu variable de entorno al secret nuevo
  4. Deploy
  5. Despues de 15 minutos, el secret viejo queda permanentemente invalido

Durante la ventana de rotacion, tu codigo puede aceptar cualquiera de los dos secrets:

function verifyVeridiaSignatureMulti(header, rawBody, secrets) {
return secrets.some(secret => verifyVeridiaSignature(header, rawBody, secret));
}

// Despues:
const valid = verifyVeridiaSignatureMulti(
sigHeader,
rawBody,
[process.env.VERIDIA_WEBHOOK_SECRET, process.env.VERIDIA_WEBHOOK_SECRET_OLD]
);

Que sigue