Signaturprüfung
Prüfen Sie den Header X-Certivant-Signature-V2 (HMAC-SHA256 über timestamp.body, 300 s Toleranz), damit Sie den Webhook-Nutzlasten vertrauen können. Mit Beispielen für Node, Python, Go, Ruby und PHP.
Jeder Webhook, den Certivant sendet, enthält einen Signatur-Header. Prüfen Sie ihn, bevor Sie irgendetwas mit der Nutzlast tun — andernfalls kann ein Unbekannter, der Ihre Webhook-URL errät, Ihnen gefälschte Ereignisse schicken, denen Ihre Anwendung dann glaubt.
Was Sie prüfen
Jede Zustellung trägt eine V2-Signatur in einem in sich geschlossenen Header:
X-Certivant-Signature-V2: t=1735689600,v1=8a2b4c...e91f
tist die Unixzeit (in Sekunden), zu der Certivant das Ereignis signiert hat.v1isthex( HMAC-SHA256( secret, "${t}.${rawBody}" ) ), wobeisecretIhr Signaturgeheimniswhsec_…ist undrawBodydie exakten Bytes des Anfrage-Bodys sind.- Während einer Geheimnisrotation kann der Header mehrere
v1=-Werte enthalten (t=…,v1=…,v1=…). Akzeptieren Sie das Ereignis, wenn einer davon passt.
Ihre Aufgabe ist es, den HMAC über t + "." + rawBody mit Ihrer Kopie des Geheimnisses neu zu berechnen, ihn mit einem Vergleich in konstanter Zeit mit jedem v1 zu vergleichen und das Ereignis abzulehnen, wenn |now - t| > 300 Sekunden. Wenn eine Signatur passt UND der Zeitstempel innerhalb des Toleranzfensters liegt, ist die Nutzlast echt und aktuell.
300-Sekunden-Toleranz
Lehnen Sie jedes Ereignis ab, dessen t mehr als 300 Sekunden (5 Minuten) von Ihrer eigenen Uhr entfernt liegt, in beide Richtungen. Halten Sie Ihre Server per NTP synchron. Der Zeitstempel ist Teil der signierten Nachricht, sodass ein Angreifer ihn nicht ändern kann, ohne die Signatur ungültig zu machen — dadurch ist eine abgefangene Anfrage nach Ablauf des Fensters für einen Replay-Angriff unbrauchbar.
Header bei jedem Webhook
| Header | Wert | Verwendung |
|---|---|---|
Content-Type | application/json | |
X-Certivant-Signature-V2 | t=<unix>,v1=<64-hex>[,v1=<64-hex>] | Diesen prüfen. HMAC über ${t}.${rawBody} |
X-Certivant-Timestamp | Unix-Sekunden | Gleicher Wert wie t, zur Protokollierung |
X-Certivant-Event-Id | Eindeutige ID | Pro Ereignis eindeutig, bleibt bei Wiederholungen gleich. Zur Idempotenz verwenden |
X-KYC-Signature | sha256=<64-hex> | Gleicher HMAC über ${X-KYC-Timestamp}.${rawBody}. Bestehende Integrationen; wird weiterhin gesendet |
X-KYC-Timestamp, X-KYC-Event-Id | Unix-Sekunden / ID | Gleiche Werte wie die X-Certivant-*-Header |
Ereignisse, die gesendet wurden, bevor Sie ein Geheimnis konfiguriert haben, tragen die ID- und Zeitstempel-Header, aber keine Signatur.
Die reine Body-Signatur der Altversion wurde entfernt
Vor Mai 2026 umfasste die Signatur nur rawBody. Dieser reine Body-Header X-KYC-Signature-Legacy, der für den 01.09.2026 als veraltet angekündigt war, wird nicht mehr gesendet (entfernt am 24. Oktober 2026), ebenso wenig X-KYC-Signature-Deprecation. Wenn Ihr Empfänger ihn noch prüft, schlägt nun jedes Ereignis fehl: Wechseln Sie auf X-Certivant-Signature-V2 (oder die zeitgestempelte X-KYC-Signature) nach dem Rezept unten.
Das Prüfverfahren
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
Drei nicht verhandelbare Regeln:
- Binden Sie den Zeitstempel in die signierte Nachricht ein, nicht nur den Body. Ohne dies kann ein Angreifer, der ein einziges gültiges Webhook abfängt, es für immer wiederholen.
- Lesen Sie den rohen Body — niemals das erneut serialisierte geparste Objekt. Reihenfolge der Schlüssel, Leerzeichen und Zahlenformate verändern den Hash.
- Vergleichen Sie mit einer Funktion mit konstanter Laufzeit (
timingSafeEqual,hmac.compare_digest,hash_equals,hmac.Equal, …), niemals mit==.
Sie können Ihren Prüfer testen, ohne Code zu schreiben: Die Schaltfläche Testereignis senden im Dashboard (KYC → Entwickler → Webhook-Ereignisse) liefert ein echtes, signiertes Ping-Ereignis kyc.webhook.test oder eine Beispielnutzlast kyc.request.* (beide enthalten "test": true) an Ihre konfigurierte URL.
Implementierungen
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]);
}
Häufige Stolperfallen
„Die Signaturen stimmen in der Produktion nicht, in der Testumgebung aber schon“
Ihr Framework parst oder verändert den Body, bevor Sie den HMAC berechnen. Der häufigste Auslöser ist Body-Parser-Middleware in Node (Expresses express.json() verändert req.body; für die Webhook-Route müssen Sie speziell express.raw() verwenden). In Next.js-API-Routen setzen Sie runtime: "nodejs" und lesen zuerst await req.text().
„Die Signaturen stimmen lokal überein, schlagen aber fehl, wenn sie über einen Load Balancer gesendet werden“
Manche Load Balancer (oder WAFs) schreiben Anfrage-Bodys um — etwa durch Formatierung, Entfernen abschließender Zeilenumbrüche oder Umkodierung von Zeichensätzen. Deaktivieren Sie auf dem Weg zu Ihrem Webhook jede Funktion, die Bodys umschreibt.
Verwendung von == statt eines Vergleichs in konstanter Zeit
// ❌ Don't do this
return expected === candidate;
// ✅ Do this (after checking the lengths are equal)
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate));
Ein einfacher Zeichenkettenvergleich bricht beim ersten abweichenden Byte ab. Ein Angreifer, der Tausende Anfragen zeitlich misst, kann das Geheimnis Byte für Byte rekonstruieren. Alle HMAC-Bibliotheken bieten eine Gleichheitsfunktion mit konstanter Laufzeit — verwenden Sie sie.
Alte Ereignisse wiederholen
Die Prüfung stellt nur die Echtheit sicher, nicht die Aktualität. Wenn Sie Webhooks protokollieren und jemand eine alte Nutzlast samt Signatur stiehlt, kann er sie erneut abspielen. Abwehrmaßnahmen:
- Verfolgen Sie
X-Certivant-Event-Idund weisen Sie Duplikate ab — siehe Idempotenz bei Ereignissen. - Lehnen Sie Ereignisse ab, deren
tmehr als 300 Sekunden von Ihrer Uhr abweicht (die oben gezeigten Prüfer tun dies).
Rotation Ihres Geheimnisses
Mit Geheimnis rotieren im Dashboard wird das alte Geheimnis für neue Zustellungen sofort ungültig. Wenn Sie auf Ihrer Seite ohne Ausfallzeit auskommen möchten, deployen Sie einen Prüfer, der beide Geheimnisse akzeptiert (das Argument secrets oben nimmt eine Liste entgegen), rotieren Sie und entfernen Sie das alte Geheimnis erst, wenn keine laufenden Wiederholungen mehr damit gesendet werden.
Den Prüfer ohne Certivant testen
Berechnen Sie eine V2-Signatur selbst und senden Sie sie per POST an Ihren Endpunkt:
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"
Wenn Ihr Handler mit 200 antwortet, funktioniert die Prüfung. Ändern Sie das Geheimnis oder den Body um ein einziges Zeichen oder senden Sie ein t, das älter als 5 Minuten ist, und bestätigen Sie, dass die Antwort 401 lautet.
Webhooks einrichten
Schritt-für-Schritt-Anleitung zum Registrieren eines Certivant-Webhook-Endpunkts, zum Speichern des Signaturgeheimnisses, zum Senden eines Testereignisses, zum Rotieren von Geheimnissen und zum Konfigurieren formularspezifischer Überschreibungen.
Webhook-Ereignisse
Vollständige Referenz für jeden Certivant-Webhook-Ereignistyp — Aufbau des Envelope-Schemas, Form der Nutzlast je Ereignis, Idempotenz-Muster und Reihenfolgeregeln.