Verificação de assinatura

Verifique o cabeçalho X-Certivant-Signature-V2 (HMAC-SHA256 sobre timestamp.corpo, com tolerância de 300 s) para confiar nos payloads de webhook. Inclui exemplos em Node, Python, Go, Ruby e PHP.

Todo webhook que a Certivant envia inclui um cabeçalho de assinatura. Verifique-o antes de fazer qualquer coisa com o payload — caso contrário, um estranho que adivinhe a sua URL de webhook pode disparar eventos falsos contra você, e a sua aplicação vai acreditar neles.

O que você está verificando

Cada entrega traz uma assinatura V2 em um único cabeçalho autocontido:

X-Certivant-Signature-V2: t=1735689600,v1=8a2b4c...e91f
  • t é o horário Unix (em segundos) em que a Certivant assinou o evento.
  • v1 é hex( HMAC-SHA256( secret, "${t}.${rawBody}" ) ), em que secret é o seu segredo de assinatura whsec_… e rawBody são os bytes exatos do corpo da requisição.
  • Durante a rotação de um segredo, o cabeçalho pode trazer vários valores v1= (t=…,v1=…,v1=…). Aceite o evento se qualquer um deles corresponder.

Sua tarefa é recalcular o HMAC sobre t + "." + rawBody com a sua cópia do segredo, comparar com cada v1 usando uma comparação em tempo constante e rejeitar o evento quando |agora - t| > 300 segundos. Se a assinatura corresponder E o timestamp estiver dentro da janela de tolerância, o payload é autêntico e recente.

Tolerância de 300 segundos

Rejeite qualquer evento cujo t esteja a mais de 300 segundos (5 minutos) do seu relógio, em qualquer direção. Mantenha os seus servidores sincronizados via NTP. O timestamp faz parte da mensagem assinada, então um atacante não consegue alterá-lo sem invalidar a assinatura — é isso que torna inútil uma requisição capturada para reenvio depois que a janela se fecha.

Cabeçalhos em todo webhook

CabeçalhoValorUso
Content-Typeapplication/json
X-Certivant-Signature-V2t=<unix>,v1=<64-hex>[,v1=<64-hex>]Verifique este cabeçalho. HMAC sobre ${t}.${rawBody}
X-Certivant-TimestampUnix em segundosMesmo valor de t, para registros em log
X-Certivant-Event-IdID únicoÚnico por evento, estável entre as novas tentativas. Use para idempotência
X-KYC-Signaturesha256=<64-hex>Mesmo HMAC sobre ${X-KYC-Timestamp}.${rawBody}. Integrações existentes; continua sendo enviado
X-KYC-Timestamp, X-KYC-Event-IdUnix em segundos / IDMesmos valores dos cabeçalhos X-Certivant-*

Eventos enviados antes de você configurar um segredo trazem os cabeçalhos de ID e timestamp, mas não trazem assinatura.

A assinatura legada apenas do corpo foi removida

Antes de maio de 2026, a assinatura cobria apenas o rawBody. O cabeçalho legado X-KYC-Signature-Legacy, apenas do corpo, anunciado como obsoleto para 2026-09-01, não é mais enviado (removido em 24 de outubro de 2026), e o mesmo vale para X-KYC-Signature-Deprecation. Se o seu receptor ainda o verifica, todos os eventos agora falham na verificação: mude para X-Certivant-Signature-V2 (ou para o X-KYC-Signature com timestamp), usando a receita abaixo.

A receita de verificação

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

Três regras inegociáveis:

  1. Vincule o timestamp à mensagem assinada, não apenas ao corpo. Sem isso, um atacante que capturar um webhook válido pode reenviá-lo para sempre.
  2. Leia o corpo bruto — nunca o objeto interpretado e serializado novamente. A ordem das chaves, os espaços em branco e a formatação dos números alteram o hash.
  3. Compare com uma função de tempo constante (timingSafeEqual, hmac.compare_digest, hash_equals, hmac.Equal, …), nunca com ==.

Teste o seu verificador sem escrever código: o botão Enviar evento de teste do painel (KYC → Desenvolvedor → Eventos de webhook) entrega um ping kyc.webhook.test real e assinado, ou um payload de exemplo kyc.request.* (ambos trazem "test": true), para a sua URL configurada.


Implementações

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]);
}

Armadilhas comuns

"As assinaturas não batem em produção, mas batiam nos testes"

O seu framework está interpretando ou modificando o corpo antes de você calcular o HMAC. O culpado mais comum é o middleware body-parser no Node (o express.json() do Express altera req.body; você deve usar express.raw() especificamente na rota do webhook). Nas rotas de API do Next.js, defina runtime: "nodejs" e leia await req.text() primeiro.

"As assinaturas batem localmente, mas falham quando passam por um load balancer"

Alguns load balancers (ou WAFs) reescrevem os corpos das requisições — formatando o JSON, removendo quebras de linha finais ou convertendo charsets. Desative qualquer recurso de reescrita de corpo no caminho até o seu webhook.

Usar == em vez de comparação em tempo 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));

A comparação simples de strings interrompe a execução no primeiro byte diferente. Um atacante que meça o tempo de milhares de requisições pode recuperar o segredo byte a byte. Todas as bibliotecas de HMAC trazem uma função de igualdade em tempo constante — use-a.

Reenvio de eventos antigos

A verificação confere apenas a autenticidade, não a atualidade. Se você registra webhooks em log e alguém rouba um payload e uma assinatura antigos, essa pessoa pode reenviá-los. Defesas:

  1. Registre o X-Certivant-Event-Id e rejeite duplicatas — veja Idempotência em Eventos.
  2. Rejeite eventos cujo t esteja a mais de 300 segundos do seu relógio (os verificadores acima fazem isso).

Rotacionar o seu segredo

O botão Rotacionar segredo do painel torna o segredo antigo inválido imediatamente para as novas entregas. Se você quer zero de indisponibilidade do seu lado, implante um verificador que aceite ambos os segredos (o argumento secrets acima recebe uma lista), rotacione e, depois, descarte o antigo quando nenhuma nova tentativa em andamento ainda o carregar.

Testar o seu verificador sem a Certivant

Calcule você mesmo uma assinatura V2 e faça um POST para o seu 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 o seu manipulador responder 200, a verificação funciona. Altere o segredo ou o corpo em um único caractere, ou envie um t com mais de 5 minutos, e confirme que a resposta é 401.