LiqiDevelopers
Segurança 15 min

Verificação de assinaturas de Webhook

Aprenda a verificar a autenticidade de webhooks enviados pela Liqi usando HMAC-SHA256. A verificação garante que o payload não foi adulterado e que realmente foi enviado pela Liqi.


Como funciona a assinatura

Cada webhook enviado pela Liqi e assinado com HMAC-SHA256 usando o webhook secret que você recebeu ao registrar o endpoint. A assinatura cobre o webhook ID, timestamp e o corpo da requisição, garantindo integridade completa.

Liqi Server                                      Seu Servidor
     |                                                  |
     |  1. Compoe o payload:                            |
     |     "{webhook_id}.{timestamp}.{body}"            |
     |                                                  |
     |  2. Calcula HMAC-SHA256 com webhook_secret       |
     |                                                  |
     |  3. Envia POST com headers:                      |
     |     X-Webhook-Signature: {hmac_hex}              |
     |     X-Webhook-Id: {webhook_id}                   |
     |     X-Webhook-Timestamp: {unix_timestamp}        |
     |  ──────────────────────────────────────────────> |
     |                                                  |
     |                          4. Extrai headers       |
     |                          5. Reconstroi payload   |
     |                          6. Calcula HMAC         |
     |                          7. Compara (timing-safe)|
     |                          8. Valida timestamp     |
     |                                                  |
     |  9. Responde 200 OK (ou 401 se invalido)        |
     |  <────────────────────────────────────────────── |
     |                                                  |

Headers de verificação

HeaderFormatoDescrição
X-Webhook-SignatureHex string (64 chars)Assinatura HMAC-SHA256 em hexadecimal
X-Webhook-Idevt_xxxxxxxxxxxxxIdentificador único do evento
X-Webhook-TimestampUnix timestamp (seg)Momento do envio (para proteção contra replay)

1Extrair headers

Extraia os três headers obrigatórios da requisição. Se qualquer um deles estiver ausente, rejeite a requisição imediatamente.

Node.js
const signature = req.headers["x-webhook-signature"] as string;
const webhookId = req.headers["x-webhook-id"] as string;
const timestamp = req.headers["x-webhook-timestamp"] as string;

if (!signature || !webhookId || !timestamp) {
  return res.status(401).json({ error: "Missing webhook headers" });
}
Python
signature = request.headers.get("X-Webhook-Signature")
webhook_id = request.headers.get("X-Webhook-Id")
timestamp = request.headers.get("X-Webhook-Timestamp")

if not all([signature, webhook_id, timestamp]):
    return jsonify({"error": "Missing webhook headers"}), 401
Go
signature := r.Header.Get("X-Webhook-Signature")
webhookId := r.Header.Get("X-Webhook-Id")
timestamp := r.Header.Get("X-Webhook-Timestamp")

if signature == "" || webhookId == "" || timestamp == "" {
    http.Error(w, "Missing webhook headers", http.StatusUnauthorized)
    return
}

2Construir o payload

O payload assinado é uma concatenação do webhook ID, timestamp e corpo da requisição, separados por ponto:

Formato do payload
{webhook_id}.{timestamp}.{raw_body}

Exemplo:
evt_abc123def456.1708534200.{"type":"payment.completed","id":"evt_abc123def456","data":{...}}

Atenção

Você deve usar o corpo bruto (raw body) da requisição, não o JSON parseado e re-serializado. Qualquer diferença na serialização (espaços, ordem de chaves) invalidará a assinatura.

3Calcular o HMAC

Calcule o HMAC-SHA256 do payload usando seu webhook secret como chave:

Node.js
import crypto from "crypto";

const WEBHOOK_SECRET = process.env.LIQI_WEBHOOK_SECRET!;

const payload = `${webhookId}.${timestamp}.${rawBody.toString()}`;

const expectedSignature = crypto
  .createHmac("sha256", WEBHOOK_SECRET)
  .update(payload)
  .digest("hex");
Python
import hmac
import hashlib
import os

WEBHOOK_SECRET = os.environ["LIQI_WEBHOOK_SECRET"]

payload = f"{webhook_id}.{timestamp}.{raw_body.decode()}"

expected_signature = hmac.new(
    WEBHOOK_SECRET.encode(),
    payload.encode(),
    hashlib.sha256
).hexdigest()
Go
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "os"
)

webhookSecret := os.Getenv("LIQI_WEBHOOK_SECRET")

payload := webhookId + "." + timestamp + "." + string(rawBody)

mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(payload))
expectedSignature := hex.EncodeToString(mac.Sum(nil))

4Comparação timing-safe

Use comparação de tempo constante para evitar ataques de timing. Nunca use === ou == para comparar assinaturas.

Node.js
// CORRETO: Comparação timing-safe
const isValid = crypto.timingSafeEqual(
  Buffer.from(signature),
  Buffer.from(expectedSignature)
);

// ERRADO: Vulnerável a timing attacks
// const isValid = signature === expectedSignature;
Python
# CORRETO: Comparação timing-safe
is_valid = hmac.compare_digest(signature, expected_signature)

# ERRADO: Vulnerável a timing attacks
# is_valid = signature == expected_signature
Go
// CORRETO: Comparação timing-safe
isValid := hmac.Equal([]byte(signature), []byte(expectedSignature))

// ERRADO: Vulnerável a timing attacks
// isValid := signature == expectedSignature

Por que timing-safe?

Comparações comuns (===) retornam mais rápido quando os primeiros bytes não coincidem. Um atacante pode usar essa diferença de tempo para descobrir a assinatura correta byte a byte. Comparações timing-safe sempre levam o mesmo tempo.

5Validar timestamp

Rejeite webhooks com timestamp mais velho que 5 minutos para proteger contra ataques de replay:

Node.js
const MAX_AGE_SECONDS = 300; // 5 minutos
const now = Math.floor(Date.now() / 1000);
const webhookTimestamp = parseInt(timestamp, 10);

if (Math.abs(now - webhookTimestamp) > MAX_AGE_SECONDS) {
  return res.status(401).json({ error: "Webhook timestamp too old" });
}
Python
import time

MAX_AGE_SECONDS = 300  # 5 minutos
now = int(time.time())
webhook_timestamp = int(timestamp)

if abs(now - webhook_timestamp) > MAX_AGE_SECONDS:
    return jsonify({"error": "Webhook timestamp too old"}), 401
Go
import (
    "math"
    "strconv"
    "time"
)

const maxAgeSeconds = 300 // 5 minutos

webhookTimestamp, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
    http.Error(w, "Invalid timestamp", http.StatusUnauthorized)
    return
}

now := time.Now().Unix()
if math.Abs(float64(now-webhookTimestamp)) > maxAgeSeconds {
    http.Error(w, "Webhook timestamp too old", http.StatusUnauthorized)
    return
}

Implementação completa

Aqui está a implementação completa de verificação em cada linguagem:

Node.js (completo)
import crypto from "crypto";

const WEBHOOK_SECRET = process.env.LIQI_WEBHOOK_SECRET!;
const MAX_AGE_SECONDS = 300;

export function verifyWebhook(
  rawBody: Buffer,
  headers: Record<string, string>
): { valid: boolean; error?: string } {
  const signature = headers["x-webhook-signature"];
  const webhookId = headers["x-webhook-id"];
  const timestamp = headers["x-webhook-timestamp"];

  // 1. Verificar headers obrigatórios
  if (!signature || !webhookId || !timestamp) {
    return { valid: false, error: "Missing required headers" };
  }

  // 2. Validar timestamp (proteção contra replay)
  const now = Math.floor(Date.now() / 1000);
  const webhookTimestamp = parseInt(timestamp, 10);

  if (isNaN(webhookTimestamp)) {
    return { valid: false, error: "Invalid timestamp format" };
  }

  if (Math.abs(now - webhookTimestamp) > MAX_AGE_SECONDS) {
    return { valid: false, error: "Timestamp too old or too far in future" };
  }

  // 3. Construir payload e calcular HMAC
  const payload = `${webhookId}.${timestamp}.${rawBody.toString()}`;

  const expectedSignature = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(payload)
    .digest("hex");

  // 4. Comparação timing-safe
  try {
    const isValid = crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    );

    if (!isValid) {
      return { valid: false, error: "Invalid signature" };
    }
  } catch {
    return { valid: false, error: "Signature length mismatch" };
  }

  return { valid: true };
}
Python (completo)
import hmac
import hashlib
import time
import os

WEBHOOK_SECRET = os.environ["LIQI_WEBHOOK_SECRET"]
MAX_AGE_SECONDS = 300

def verify_webhook(
    raw_body: bytes,
    headers: dict
) -> tuple[bool, str | None]:
    """Verifica a assinatura do webhook. Retorna (valid, error)."""

    signature = headers.get("X-Webhook-Signature")
    webhook_id = headers.get("X-Webhook-Id")
    timestamp = headers.get("X-Webhook-Timestamp")

    # 1. Verificar headers obrigatórios
    if not all([signature, webhook_id, timestamp]):
        return False, "Missing required headers"

    # 2. Validar timestamp (proteção contra replay)
    try:
        webhook_timestamp = int(timestamp)
    except ValueError:
        return False, "Invalid timestamp format"

    now = int(time.time())
    if abs(now - webhook_timestamp) > MAX_AGE_SECONDS:
        return False, "Timestamp too old or too far in future"

    # 3. Construir payload e calcular HMAC
    payload = f"{webhook_id}.{timestamp}.{raw_body.decode()}"

    expected_signature = hmac.new(
        WEBHOOK_SECRET.encode(),
        payload.encode(),
        hashlib.sha256
    ).hexdigest()

    # 4. Comparação timing-safe
    if not hmac.compare_digest(signature, expected_signature):
        return False, "Invalid signature"

    return True, None
Go (completo)
package webhook

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

var (
    webhookSecret  = os.Getenv("LIQI_WEBHOOK_SECRET")
    maxAgeSeconds  = 300.0
)

func VerifyWebhook(r *http.Request, rawBody []byte) error {
    signature := r.Header.Get("X-Webhook-Signature")
    webhookId := r.Header.Get("X-Webhook-Id")
    timestamp := r.Header.Get("X-Webhook-Timestamp")

    // 1. Verificar headers obrigatórios
    if signature == "" || webhookId == "" || timestamp == "" {
        return fmt.Errorf("missing required headers")
    }

    // 2. Validar timestamp
    webhookTimestamp, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return fmt.Errorf("invalid timestamp format")
    }

    now := time.Now().Unix()
    if math.Abs(float64(now-webhookTimestamp)) > maxAgeSeconds {
        return fmt.Errorf("timestamp too old")
    }

    // 3. Construir payload e calcular HMAC
    payload := webhookId + "." + timestamp + "." + string(rawBody)

    mac := hmac.New(sha256.New, []byte(webhookSecret))
    mac.Write([]byte(payload))
    expectedSignature := hex.EncodeToString(mac.Sum(nil))

    // 4. Comparação timing-safe
    if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
        return fmt.Errorf("invalid signature")
    }

    return nil
}

Boas práticas de segurança

  • Sempre use comparação timing-safe crypto.timingSafeEqual (Node.js), hmac.compare_digest (Python), hmac.Equal (Go).
  • Valide o timestamp — Rejeite webhooks com mais de 5 minutos de diferença para proteger contra replay attacks.
  • Use raw body — Nunca re-serialize o JSON parseado. Middleware como express.json() consume o body. Use express.raw() para a rota de webhook.
  • Armazene o secret com segurança — Use variável de ambiente ou cofre de segredos. Nunca inclua no código fonte.
  • Implemente idempotência — Armazene o X-Webhook-Id em banco de dados e verifique antes de processar. Webhooks podem ser entregues mais de uma vez.
  • Monitore rejeições — Registre tentativas com assinatura inválida em log. Muitas tentativas podem indicar um ataque.

Testando com payloads de exemplo

Use o script abaixo para gerar uma assinatura e testar seu endpoint localmente:

Node.js — Gerar webhook de teste
import crypto from "crypto";

const SECRET = "whsec_test_secret_for_development";
const WEBHOOK_ID = "evt_test_123";
const TIMESTAMP = Math.floor(Date.now() / 1000).toString();

const body = JSON.stringify({
  type: "payment.completed",
  id: WEBHOOK_ID,
  createdAt: new Date().toISOString(),
  data: {
    trancheTicker: "ROB1SR06",
    installmentNumber: 5,
    totalValue: "3095.00",
    status: "PAID"
  }
});

const payload = `${WEBHOOK_ID}.${TIMESTAMP}.${body}`;
const signature = crypto
  .createHmac("sha256", SECRET)
  .update(payload)
  .digest("hex");

console.log("Headers para teste:");
console.log(`  X-Webhook-Signature: ${signature}`);
console.log(`  X-Webhook-Id: ${WEBHOOK_ID}`);
console.log(`  X-Webhook-Timestamp: ${TIMESTAMP}`);
console.log(`\nBody:\n${body}`);
cURL — Enviar webhook de teste
# Defina as variáveis (saída do script acima)
SIGNATURE="a1b2c3d4e5f6..."
WEBHOOK_ID="evt_test_123"
TIMESTAMP="1708534200"
BODY='{"type":"payment.completed","id":"evt_test_123","data":{"trancheTicker":"ROB1SR06","installmentNumber":5,"totalValue":"3095.00","status":"PAID"}}'

curl -X POST "http://localhost:3001/webhooks/liqi" \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Signature: $SIGNATURE" \
  -H "X-Webhook-Id: $WEBHOOK_ID" \
  -H "X-Webhook-Timestamp: $TIMESTAMP" \
  -d "$BODY"

Dica

Em ambiente de desenvolvimento, use uma chave de teste fixa como whsec_test_secret_for_development. Nunca use sua chave de produção em testes locais.

Próximos passos