Verificación de firma

Verifique la cabecera X-Certivant-Signature-V2 (HMAC-SHA256 sobre timestamp.body, con tolerancia de 300 s) para confiar en los payloads de webhook. Incluye ejemplos en Node, Python, Go, Ruby y PHP.

Cada webhook que envía Certivant incluye una cabecera de firma. Verifíquela antes de hacer nada con el payload: de lo contrario, un desconocido que adivine su URL de webhook podría enviarle eventos falsos, y su aplicación los creería.

Qué se verifica

Cada entrega lleva una firma V2 en una única cabecera autocontenida:

X-Certivant-Signature-V2: t=1735689600,v1=8a2b4c...e91f
  • t es la hora Unix (en segundos) en la que Certivant firmó el evento.
  • v1 es hex( HMAC-SHA256( secret, "${t}.${rawBody}" ) ), donde secret es su secreto de firma whsec_… y rawBody son los bytes exactos del cuerpo de la petición.
  • Durante la rotación de un secreto, la cabecera puede llevar varios valores v1= (t=…,v1=…,v1=…). Acepte el evento si alguno coincide.

Su trabajo es recalcular el HMAC sobre t + "." + rawBody con su copia del secreto, comparar cada v1 mediante una comparación en tiempo constante y rechazar el evento cuando |ahora - t| > 300 segundos. Si una firma coincide Y la marca de tiempo está dentro de la ventana de tolerancia, el payload es auténtico y reciente.

Tolerancia de 300 segundos

Rechace cualquier evento cuyo t se diferencie en más de 300 segundos (5 minutos) de su propio reloj, en cualquier sentido. Mantenga sus servidores sincronizados con NTP. La marca de tiempo forma parte del mensaje firmado, así que un atacante no puede cambiarla sin invalidar la firma: esto es lo que hace inútil una petición capturada para reproducirla cuando se cierra la ventana.

Cabeceras en cada webhook

CabeceraValorUso
Content-Typeapplication/json
X-Certivant-Signature-V2t=<unix>,v1=<64-hex>[,v1=<64-hex>]Verifique esta. HMAC sobre ${t}.${rawBody}
X-Certivant-TimestampSegundos UnixEl mismo valor que t, para los registros
X-Certivant-Event-IdId únicoÚnico por evento, estable entre reintentos. Úselo para la idempotencia
X-KYC-Signaturesha256=<64-hex>El mismo HMAC sobre ${X-KYC-Timestamp}.${rawBody}. Integraciones existentes; sigue enviándose
X-KYC-Timestamp, X-KYC-Event-IdSegundos Unix / idLos mismos valores que las cabeceras X-Certivant-*

Los eventos enviados antes de que haya configurado un secreto llevan las cabeceras de id y marca de tiempo, pero no firma.

La firma heredada solo con el cuerpo se ha eliminado

Antes de mayo de 2026, la firma cubría solo rawBody. Esa cabecera heredada X-KYC-Signature-Legacy, anunciada como obsoleta para el 2026-09-01, ya no se envía (se eliminó el 24 October 2026), y tampoco X-KYC-Signature-Deprecation. Si su receptor todavía la comprueba, cada evento fallará la verificación: cámbiese a X-Certivant-Signature-V2 (o a la X-KYC-Signature con marca de tiempo) siguiendo la receta de más abajo.

La receta de verificación

function verify(rawBody, signatureV2Header, secrets):
  parts = split(signatureV2Header, ",")
  t     = value of the part "t=..."
  sigs  = values of every part "v1=..."
  if t is missing or sigs is empty:      return false
  if abs(now() - int(t)) > 300:          return false      # replay protection
  signed = t + "." + rawBody
  for secret in secrets:                                   # >1 only while rotating
    expected = hex(hmac_sha256(secret, signed))
    for sig in sigs:
      if constant_time_equal(expected, sig): return true
  return false

Tres reglas innegociables:

  1. Incluya la marca de tiempo en el mensaje firmado, no solo el cuerpo. Sin esto, un atacante que capture un webhook válido podría reproducirlo para siempre.
  2. Lea el cuerpo sin procesar, nunca el objeto analizado y vuelto a serializar. El orden de las claves, los espacios en blanco y el formato de los números cambian el hash.
  3. Compare con una función de tiempo constante (timingSafeEqual, hmac.compare_digest, hash_equals, hmac.Equal, …), nunca con ==.

Pruebe su verificador sin escribir código: el botón Enviar un evento de prueba del panel (KYC → Desarrollador → Eventos de webhook) entrega a su URL configurada un ping kyc.webhook.test real y firmado, o un payload de ejemplo kyc.request.* (ambos llevan "test": true).


Implementaciones

import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300; // reject events more than 5 minutes off

/**
 * Verify an X-Certivant-Signature-V2 header ("t=<unix>,v1=<hex>[,v1=<hex>]").
 * `secrets` may hold two values while you rotate (old + new).
 */
export function verifyCertivantSignature(
  rawBody: string,
  signatureHeader: string | undefined | null,
  secrets: string | string[],
  toleranceSeconds = TOLERANCE_SECONDS,
): boolean {
  if (!signatureHeader) return false;

  // 1. Parse "t=...,v1=...,v1=..."
  let t = "";
  const candidates: string[] = [];
  for (const part of signatureHeader.split(",")) {
    const i = part.indexOf("=");
    if (i < 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === "t") t = value;
    else if (key === "v1") candidates.push(value);
  }
  if (!/^\d+$/.test(t) || candidates.length === 0) return false;

  // 2. Reject stale / future-dated events (replay protection)
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;

  // 3. Recompute HMAC over `${t}.${rawBody}` — the timestamp is part of the
  //    signed message — and compare in constant time against every v1.
  for (const secret of Array.isArray(secrets) ? secrets : [secrets]) {
    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${t}.${rawBody}`)
      .digest("hex");
    for (const candidate of candidates) {
      const a = Buffer.from(expected);
      const b = Buffer.from(candidate);
      if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true;
    }
  }
  return false;
}

// Hono usage
app.post("/webhooks/certivant", async (c) => {
  const rawBody = await c.req.text();   // <-- raw, not c.req.json()
  const sig = c.req.header("X-Certivant-Signature-V2");
  if (!verifyCertivantSignature(rawBody, sig, process.env.CERTIVANT_WEBHOOK_SECRET!)) {
    return c.json({ error: "Invalid signature" }, 401);
  }
  const payload = JSON.parse(rawBody);
  // De-duplicate on c.req.header("X-Certivant-Event-Id"), then handle payload
  return c.json({ received: true });
});

// Express usage — needs express.raw() to keep the raw body
import express from "express";
const app = express();
app.post(
  "/webhooks/certivant",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body.toString();  // Buffer -> string
    const sig = req.header("X-Certivant-Signature-V2");
    if (!verifyCertivantSignature(rawBody, sig, process.env.CERTIVANT_WEBHOOK_SECRET!)) {
      return res.status(401).json({ error: "Invalid signature" });
    }
    const payload = JSON.parse(rawBody);
    res.json({ received: true });
  },
);
import hashlib
import hmac
import re
import time

TOLERANCE_SECONDS = 300  # reject events more than 5 minutes off


def verify_certivant_signature(
    raw_body: bytes,
    signature_header: str | None,
    secrets: str | list[str],
    tolerance_seconds: int = TOLERANCE_SECONDS,
) -> bool:
    """Verify X-Certivant-Signature-V2 ("t=<unix>,v1=<hex>[,v1=<hex>]").

    `secrets` may hold two values while you rotate (old + new)."""
    if not signature_header:
        return False

    # 1. Parse "t=...,v1=...,v1=..."
    t = ""
    candidates: list[str] = []
    for part in signature_header.split(","):
        key, sep, value = part.partition("=")
        if not sep:
            continue
        key, value = key.strip(), value.strip()
        if key == "t":
            t = value
        elif key == "v1":
            candidates.append(value)
    if not re.fullmatch(r"\d+", t) or not candidates:
        return False

    # 2. Reject stale / future-dated events (replay protection)
    if abs(time.time() - int(t)) > tolerance_seconds:
        return False

    # 3. Recompute HMAC over `${t}.${rawBody}`. We feed bytes so the body
    #    is never accidentally re-encoded; compare in constant time.
    signed = t.encode("ascii") + b"." + raw_body
    for secret in [secrets] if isinstance(secrets, str) else secrets:
        expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(expected, c) for c in candidates):
            return True
    return False


# FastAPI
from fastapi import FastAPI, HTTPException, Request
import os

app = FastAPI()

@app.post("/webhooks/certivant")
async def handle(request: Request):
    raw_body = await request.body()          # bytes, untouched
    sig = request.headers.get("x-certivant-signature-v2")
    if not verify_certivant_signature(raw_body, sig, os.environ["CERTIVANT_WEBHOOK_SECRET"]):
        raise HTTPException(status_code=401, detail="Invalid signature")
    payload = await request.json()
    return {"received": True}

# Flask
from flask import Flask, abort, request

app = Flask(__name__)

@app.post("/webhooks/certivant")
def handle():
    raw_body = request.get_data()
    sig = request.headers.get("X-Certivant-Signature-V2")
    if not verify_certivant_signature(raw_body, sig, os.environ["CERTIVANT_WEBHOOK_SECRET"]):
        abort(401)
    payload = request.get_json()
    return {"received": True}
package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "math"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

const toleranceSeconds = 300

// Verifies X-Certivant-Signature-V2 ("t=<unix>,v1=<hex>[,v1=<hex>]").
// Pass two secrets while rotating (old + new).
func verifyCertivantSignature(rawBody []byte, header string, secrets ...string) bool {
    var t string
    var candidates []string
    for _, part := range strings.Split(header, ",") {
        kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
        if len(kv) != 2 {
            continue
        }
        switch kv[0] {
        case "t":
            t = kv[1]
        case "v1":
            candidates = append(candidates, kv[1])
        }
    }
    ts, err := strconv.ParseInt(t, 10, 64)
    if err != nil || len(candidates) == 0 {
        return false
    }
    if math.Abs(float64(time.Now().Unix()-ts)) > toleranceSeconds {
        return false // replay protection
    }
    for _, secret := range secrets {
        mac := hmac.New(sha256.New, []byte(secret))
        mac.Write([]byte(t + "."))
        mac.Write(rawBody)
        expected := hex.EncodeToString(mac.Sum(nil))
        for _, c := range candidates {
            if hmac.Equal([]byte(expected), []byte(c)) {
                return true
            }
        }
    }
    return false
}

func Handler(w http.ResponseWriter, r *http.Request) {
    rawBody, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "read body", http.StatusBadRequest)
        return
    }
    sig := r.Header.Get("X-Certivant-Signature-V2")
    if !verifyCertivantSignature(rawBody, sig, os.Getenv("CERTIVANT_WEBHOOK_SECRET")) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }
    // unmarshal rawBody into your struct and handle...
    w.WriteHeader(http.StatusOK)
}
require "openssl"

TOLERANCE_SECONDS = 300

# Verifies X-Certivant-Signature-V2 ("t=<unix>,v1=<hex>[,v1=<hex>]").
# `secrets` may hold two values while you rotate (old + new).
def verify_certivant_signature(raw_body, signature_header, secrets)
  return false if signature_header.nil? || signature_header.empty?
  parts = signature_header.split(",").map { |p| p.strip.split("=", 2) }
  t = parts.find { |k, _| k == "t" }&.last.to_s
  candidates = parts.select { |k, _| k == "v1" }.map(&:last)
  return false unless t.match?(/\A\d+\z/) && !candidates.empty?
  return false if (Time.now.to_i - t.to_i).abs > TOLERANCE_SECONDS # replay protection

  Array(secrets).any? do |secret|
    expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
    candidates.any? { |c| Rack::Utils.secure_compare(expected, c) }
  end
end

# Rails
class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token, only: [:certivant]

  def certivant
    raw_body = request.raw_post
    sig = request.headers["X-Certivant-Signature-V2"]
    unless verify_certivant_signature(raw_body, sig, ENV["CERTIVANT_WEBHOOK_SECRET"])
      head :unauthorized and return
    end
    payload = JSON.parse(raw_body)
    # ... handle
    render json: { received: true }
  end
end
<?php
const CERTIVANT_TOLERANCE_SECONDS = 300;

// Verifies X-Certivant-Signature-V2 ("t=<unix>,v1=<hex>[,v1=<hex>]").
// $secrets may hold two values while you rotate (old + new).
function verify_certivant_signature(string $rawBody, ?string $signatureHeader, string|array $secrets): bool {
    if ($signatureHeader === null || $signatureHeader === "") return false;
    $t = "";
    $candidates = [];
    foreach (explode(",", $signatureHeader) as $part) {
        $kv = explode("=", trim($part), 2);
        if (count($kv) !== 2) continue;
        if ($kv[0] === "t") $t = $kv[1];
        elseif ($kv[0] === "v1") $candidates[] = $kv[1];
    }
    if (!ctype_digit($t) || count($candidates) === 0) return false;
    if (abs(time() - (int) $t) > CERTIVANT_TOLERANCE_SECONDS) return false; // replay protection

    foreach ((array) $secrets as $secret) {
        $expected = hash_hmac("sha256", $t . "." . $rawBody, $secret);
        foreach ($candidates as $candidate) {
            if (hash_equals($expected, $candidate)) return true;
        }
    }
    return false;
}

// Plain PHP
$rawBody = file_get_contents("php://input");
$sig = $_SERVER["HTTP_X_CERTIVANT_SIGNATURE_V2"] ?? null;
if (!verify_certivant_signature($rawBody, $sig, getenv("CERTIVANT_WEBHOOK_SECRET"))) {
    http_response_code(401);
    echo json_encode(["error" => "Invalid signature"]);
    exit;
}
$payload = json_decode($rawBody, true);
// ... handle
echo json_encode(["received" => true]);

// Laravel
public function certivant(\Illuminate\Http\Request $request) {
    $rawBody = $request->getContent();
    $sig = $request->header("X-Certivant-Signature-V2");
    if (!verify_certivant_signature($rawBody, $sig, env("CERTIVANT_WEBHOOK_SECRET"))) {
        abort(401);
    }
    $payload = json_decode($rawBody, true);
    return response()->json(["received" => true]);
}

Errores comunes

«Las firmas no coinciden en producción, pero sí en pruebas»

Su framework analiza o modifica el cuerpo antes de calcular el HMAC. El culpable más habitual es el middleware de análisis del cuerpo en Node (express.json() modifica req.body; para la ruta del webhook debe usar express.raw()). En las rutas de API de Next.js, configure runtime: "nodejs" y lea primero await req.text().

«Las firmas coinciden en local, pero fallan cuando pasan por un balanceador de carga»

Algunos balanceadores de carga (o WAF) reescriben los cuerpos de las peticiones: los formatean, eliminan los saltos de línea finales o transcodifican el juego de caracteres. Desactive cualquier función de reescritura del cuerpo en el camino hasta su webhook.

Usar == en lugar de una comparación en tiempo constante

// ❌ Don't do this
return expected === candidate;

// ✅ Do this (after checking the lengths are equal)
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate));

La comparación de cadenas normal se interrumpe en el primer byte que no coincide. Un atacante que mida el tiempo de miles de peticiones podría recuperar el secreto byte a byte. Todas las bibliotecas HMAC incluyen una función de igualdad en tiempo constante: úsela.

Reproducir eventos antiguos

La verificación solo comprueba la autenticidad, no la actualidad. Si registra los webhooks y alguien roba un payload antiguo junto con su firma, puede reproducirlo. Defensas:

  1. Registre X-Certivant-Event-Id y rechace los duplicados; consulte Idempotencia en Eventos.
  2. Rechace los eventos cuyo t se diferencie más de 300 segundos de su reloj (los verificadores anteriores lo hacen).

Rotar su secreto

El botón Rotar secreto del panel invalida de inmediato el secreto antiguo para las nuevas entregas. Si quiere cero tiempo de inactividad en su lado, despliegue un verificador que acepte ambos secretos (el argumento secrets de los ejemplos anteriores admite una lista), rote y luego elimine el antiguo cuando ningún reintento en vuelo lo siga usando.

Probar su verificador sin Certivant

Calcule usted mismo una firma V2 y envíela por POST a su endpoint:

SECRET=whsec_test
BODY='{"event":"kyc.request.approved","requestId":"req_test"}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')

curl -X POST http://localhost:3000/webhooks/certivant \
  -H "Content-Type: application/json" \
  -H "X-Certivant-Signature-V2: t=$T,v1=$SIG" \
  -d "$BODY"

Si su manejador responde 200, la verificación funciona. Modifique el secreto o el cuerpo en un solo carácter, o envíe un t con más de 5 minutos de antigüedad, y compruebe que devuelve 401.