Verifica della firma
Verifichi l'intestazione X-Certivant-Signature-V2 (HMAC-SHA256 su timestamp.body, tolleranza di 300 s) per fidarsi dei payload dei webhook. Include esempi in Node, Python, Go, Ruby e PHP.
Ogni webhook che Certivant invia include un'intestazione di firma. Lo verifichi prima di fare qualsiasi cosa con il payload — altrimenti un estraneo che indovina il Suo URL webhook può inviarle eventi falsi, e la Sua applicazione li considererà veri.
Che cosa si verifica
Ogni consegna porta una firma V2 in un'unica intestazione autosufficiente:
X-Certivant-Signature-V2: t=1735689600,v1=8a2b4c...e91f
tè il tempo Unix (in secondi) in cui Certivant ha firmato l'evento.v1èhex( HMAC-SHA256( secret, "${t}.${rawBody}" ) ), dovesecretè il Suo segreto di firmawhsec_…erawBodysono i byte esatti del corpo della richiesta.- Durante la rotazione di un segreto l'intestazione può contenere più valori
v1=(t=…,v1=…,v1=…). Accetti l'evento se uno qualsiasi di essi corrisponde.
Il Suo compito è ricalcolare l'HMAC su t + "." + rawBody con la Sua copia del segreto, confrontarlo con ciascun v1 con un confronto a tempo costante, e rifiutare l'evento quando |now - t| > 300 secondi. Se una firma corrisponde E il timestamp rientra nella finestra di tolleranza, il payload è autentico e recente.
Tolleranza di 300 secondi
Rifiuti ogni evento il cui t si discosta di più di 300 secondi (5 minuti) dal Suo orologio, in entrambe le direzioni. Tenga i Suoi server sincronizzati tramite NTP. Il timestamp fa parte del messaggio firmato, quindi un aggressore non può modificarlo senza invalidare la firma — ed è questo che rende inutile una richiesta catturata per un replay dopo la chiusura della finestra.
Intestazioni di ogni webhook
| Intestazione | Valore | Uso |
|---|---|---|
Content-Type | application/json | |
X-Certivant-Signature-V2 | t=<unix>,v1=<64-hex>[,v1=<64-hex>] | Verifichi questo. HMAC su ${t}.${rawBody} |
X-Certivant-Timestamp | secondi Unix | Stesso valore di t, per il log |
X-Certivant-Event-Id | ID univoco | Univoco per evento, stabile tra i nuovi tentativi. Da usare per l'idempotenza |
X-KYC-Signature | sha256=<64-hex> | Stesso HMAC su ${X-KYC-Timestamp}.${rawBody}. Integrazioni esistenti; continua a essere inviata |
X-KYC-Timestamp, X-KYC-Event-Id | secondi Unix / ID | Stessi valori delle intestazioni X-Certivant-* |
Gli eventi inviati prima che Lei abbia configurato un segreto portano le intestazioni di ID e timestamp, ma nessuna firma.
La firma legacy basata solo sul corpo è stata rimossa
Prima di maggio 2026 la firma copriva solo rawBody. Quella intestazione X-KYC-Signature-Legacy basata solo sul corpo, annunciata come obsoleta per il 2026-09-01, non viene più inviata (rimossa il 24 ottobre 2026), e non lo è nemmeno X-KYC-Signature-Deprecation. Se il Suo ricevitore la controlla ancora, ogni evento ora fallisce la verifica: passi a X-Certivant-Signature-V2 (oppure alla firma con timestamp X-KYC-Signature) usando la ricetta qui sotto.
La ricetta di verifica
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
Tre regole non negoziabili:
- Includa il timestamp nel messaggio firmato, non solo il corpo. Senza questo, un aggressore che cattura un webhook valido può riprodurlo all'infinito.
- Legga il corpo grezzo — mai l'oggetto analizzato e riserializzato. L'ordine delle chiavi, gli spazi e la formattazione dei numeri cambiano l'hash.
- Confronti con una funzione a tempo costante (
timingSafeEqual,hmac.compare_digest,hash_equals,hmac.Equal, …), mai con==.
Verifichi il Suo verificatore senza scrivere alcun codice: il pulsante Invia un evento di prova della dashboard (KYC → Sviluppatore → Eventi webhook) consegna al Suo URL configurato un vero ping kyc.webhook.test firmato, oppure un payload di esempio kyc.request.* (entrambi contengono "test": true).
Implementazioni
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]);
}
Errori comuni
«Le firme non corrispondono in produzione, ma corrispondevano nei test»
Il Suo framework analizza o modifica il corpo prima che Lei calcoli l'HMAC. Il colpevole più comune è il middleware body-parser in Node (express.json() modifica req.body; per la route del webhook deve usare specificamente express.raw()). Nelle route API di Next.js, imposti runtime: "nodejs" e legga prima await req.text().
«Le firme corrispondono in locale, ma falliscono quando passano da un load balancer»
Alcuni load balancer (o WAF) riscrivono i corpi delle richieste — formattandoli, eliminando i newline finali o convertendo i set di caratteri. Disattivi ogni funzione di riscrittura del corpo lungo il percorso verso il Suo webhook.
Usare == invece del confronto a tempo costante
// ❌ Don't do this
return expected === candidate;
// ✅ Do this (after checking the lengths are equal)
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate));
Il confronto semplice tra stringhe si interrompe al primo byte diverso. Un aggressore che misura i tempi di migliaia di richieste può ricostruire il segreto byte per byte. Tutte le librerie HMAC includono una funzione di uguaglianza a tempo costante — la usi.
Riproduzione di eventi vecchi
La verifica controlla solo l'autenticità, non la freschezza. Se registra i webhook e qualcuno ruba un payload vecchio con la sua firma, può riprodurlo. Difese:
- Tracci
X-Certivant-Event-Ide rifiuti i duplicati — vedi Idempotenza negli Eventi. - Rifiuti gli eventi il cui
tsi discosta di più di 300 secondi dal Suo orologio (i verificatori sopra lo fanno).
Rotazione del segreto
Il pulsante Ruota segreto della dashboard rende immediatamente non valido il vecchio segreto per le nuove consegne. Se vuole un'interruzione pari a zero dalla Sua parte, distribuisca un verificatore che accetta entrambi i segreti (l'argomento secrets sopra accetta un elenco), ruoti, quindi elimini il vecchio una volta che nessun nuovo tentativo in corso lo porta più.
Test del verificatore senza Certivant
Calcoli Lei stesso una firma V2 e la invii con POST al Suo 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"
Se il Suo gestore risponde 200, la verifica funziona. Modifichi di un carattere il segreto o il corpo, oppure invii un t più vecchio di 5 minuti, e confermi che restituisca 401.
Configurazione dei webhook
Guida passo passo per registrare un endpoint webhook di Certivant, conservare il segreto di firma, inviare un evento di prova, ruotare i segreti e configurare le sostituzioni per modulo.
Eventi webhook
Riferimento completo di ogni tipo di evento webhook di Certivant — schema della busta, forme dei payload per evento, schema di idempotenza e regole di ordinamento.