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:
| Key | Descripcion |
|---|---|
t | Unix timestamp (segundos) cuando enviamos el webhook |
v1 | HMAC-SHA256 hex-encoded de <timestamp>.<raw_body> usando tu webhook secret |
El algoritmo
Para verificar:
- Parsea
tyv1del header - Concatena:
signedPayload = timestamp + "." + raw_request_body - Calcula:
expectedV1 = HMAC_SHA256(tuSecret, signedPayload).toHex() - Compara
expectedV1conv1usando comparacion constant-time - Verifica
|now - timestamp| < 300segundos (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
| Error | Sintoma | Fix |
|---|---|---|
| Usar JSON parseado en lugar de bytes raw | La firma nunca matchea | Usa express.raw() / request.get_data() / php://input |
| Comparacion directa de strings en lugar de constant-time | Vulnerabilidad de timing attack | Usa crypto.timingSafeEqual / hmac.compare_digest / hash_equals |
| Falta proteccion replay | Requests capturadas pueden ser repetidas para siempre | Rechaza si ` |
| Usar el secret equivocado | Todas las firmas fallan | Cada tenant tiene su propio secret — verifica el activo en el dashboard |
| Trimear whitespace del body | Mismatch de firma | No transformes el body antes de calcular HMAC |
Rotando el webhook secret
Para rotar sin downtime:
- En el dashboard, hace click en Rotate sobre tu webhook secret
- Tanto el secret viejo como el nuevo funcionan por 15 minutos
- Actualiza tu variable de entorno al secret nuevo
- Deploy
- 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
- Tipos de eventos — schemas completos para cada evento
- Ejemplos — implementaciones de handler completas
- Webhooks overview — volver al overview de webhooks