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
  • t ist die Unixzeit (in Sekunden), zu der Certivant das Ereignis signiert hat.
  • v1 ist hex( HMAC-SHA256( secret, "${t}.${rawBody}" ) ), wobei secret Ihr Signaturgeheimnis whsec_… ist und rawBody die 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

HeaderWertVerwendung
Content-Typeapplication/json
X-Certivant-Signature-V2t=<unix>,v1=<64-hex>[,v1=<64-hex>]Diesen prüfen. HMAC über ${t}.${rawBody}
X-Certivant-TimestampUnix-SekundenGleicher Wert wie t, zur Protokollierung
X-Certivant-Event-IdEindeutige IDPro Ereignis eindeutig, bleibt bei Wiederholungen gleich. Zur Idempotenz verwenden
X-KYC-Signaturesha256=<64-hex>Gleicher HMAC über ${X-KYC-Timestamp}.${rawBody}. Bestehende Integrationen; wird weiterhin gesendet
X-KYC-Timestamp, X-KYC-Event-IdUnix-Sekunden / IDGleiche 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:

  1. 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.
  2. Lesen Sie den rohen Body — niemals das erneut serialisierte geparste Objekt. Reihenfolge der Schlüssel, Leerzeichen und Zahlenformate verändern den Hash.
  3. 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:

  1. Verfolgen Sie X-Certivant-Event-Id und weisen Sie Duplikate ab — siehe Idempotenz bei Ereignissen.
  2. Lehnen Sie Ereignisse ab, deren t mehr 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.