Pular para o conteúdo

Verificação de Assinatura de Webhook: Por Que HMAC-SHA256 É a Única Maneira Segura de Confiar em um Callback de Pagamento

Payora7 min de leituraEN · RU · UK · ES · DE · FR · IT · PL · PT · HI · ZH

Um webhook de pagamento chega ao seu servidor afirmando que um pedido foi pago. Você confia nisso? Se você creditar o pedido com base apenas na chamada HTTP — sem verificar uma assinatura criptográfica — você acabou de dar aos atacantes uma maneira gratuita de marcar qualquer pedido como pago.

O Que Pode Dar Errado Sem Verificação

Um endpoint de webhook desprotegido é trivialmente explorável. Um atacante só precisa saber sua URL de callback — que muitas vezes pode ser descoberta na aba de rede de um navegador, em um repositório público do GitHub ou em um checkout de teste — e eles podem POSTar uma carga útil manipulada:

POST /webhook HTTP/1.1
Content-Type: application/json

{"order_id": "1337", "status": "paid", "amount": "99.99"}

Se seu manipulador lê $_POST['status'] === 'paid' e credita o pedido, o atacante acabou de comprar $99.99 em mercadorias de graça. Este não é um ataque teórico; acontece regularmente contra integrações de pagamento desprotegidas.

Como Funciona a Assinatura HMAC-SHA256

HMAC (Código de Autenticação de Mensagem Baseado em Hash) com SHA-256 resolve isso tornando a carga útil inautenticável sem a chave secreta. O processo é:

  1. O gateway de pagamento conhece seu api_secret — um segredo compartilhado apenas entre você e o gateway.
  2. Antes de enviar o webhook, ele calcula: HMAC-SHA256(timestamp + "." + body, api_secret)
  3. Ele inclui esta assinatura e o timestamp nos cabeçalhos HTTP.
  4. Seu servidor recalcula o mesmo HMAC e compara. Se eles coincidirem, a carga útil é autêntica.

Um atacante que não conhece seu api_secret não pode forjar uma assinatura válida — SHA-256 é uma função unidirecional. O timestamp previne ataques de repetição: mesmo um webhook interceptado válido se torna inútil após 5 minutos.

Implementando Verificação: PHP

function verifyPayoraWebhook(string $secret): array {
    $ts   = $_SERVER['HTTP_X_PAYORA_TIMESTAMP'] ?? '';
    $sig  = $_SERVER['HTTP_X_PAYORA_SIGNATURE'] ?? '';
    $body = file_get_contents('php://input');

    // 1. rejeitar timestamps antigos (proteção contra repetição)
    if (!ctype_digit($ts) || abs(time() - (int)$ts) > 300) {
        http_response_code(400); exit('timestamp antigo');
    }

    // 2. recomputar e comparar — hash_equals() previne ataques de tempo
    $expected = hash_hmac('sha256', $ts . '.' . $body, $secret);
    if (!hash_equals($expected, $sig)) {
        http_response_code(401); exit('assinatura inválida');
    }

    return json_decode($body, true);
}

$event = verifyPayoraWebhook('sk_your_secret');
if ($event['status'] === 'paid') {
    creditOrder($event['order_id']);
}

Implementando Verificação: Node.js

import crypto from 'node:crypto';

function verifyPayoraWebhook(req, rawBody, secret) {
    const ts  = req.headers['x-payora-timestamp'] ?? '';
    const sig = req.headers['x-payora-signature'] ?? '';

    if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
        return null; // antigo
    }

    const expected = crypto
        .createHmac('sha256', secret)
        .update(`${ts}.${rawBody}`)
        .digest('hex');

    // timingSafeEqual previne ataques de canal lateral de tempo
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
        ? JSON.parse(rawBody)
        : null;
}

Erros Comuns Que Quebram a Verificação

1. Lendo um corpo analisado em vez dos bytes brutos. O middleware json() do Express re-serializa o JSON, o que pode reordenar chaves ou mudar espaços em branco. Sempre armazene em buffer o corpo da requisição bruta antes de analisar, e use isso para o HMAC.

2. Usando === em vez de uma comparação segura em tempo. Uma comparação de string ingênua vaza informações de tempo que podem ser exploradas para adivinhar a assinatura byte a byte. Use hash_equals() em PHP ou crypto.timingSafeEqual() em Node.

3. Pulando a verificação do timestamp. Um webhook válido, mas antigo, capturado por um observador de rede poderia ser reproduzido. A janela de 5 minutos (±300 segundos) elimina esse risco enquanto lida com um desvio de relógio razoável.

4. Registrando o corpo bruto antes da verificação. Se seu pipeline de registro for comprometido, as cargas registradas poderiam ser reproduzidas. Verifique primeiro, registre depois.

Além da Assinatura: Idempotência

A verificação prova que o webhook é autêntico; a idempotência garante que você não credite um pedido duas vezes. Redes reenviam webhooks em respostas não-200, então seu manipulador pode receber o mesmo evento várias vezes. A solução é registrar um webhook_id em seu banco de dados e pular o processamento se você já o viu antes:

// pseudo-código
$id = $event['webhook_id'];
if (DB::exists('processed_webhooks', $id)) return respond(200);
DB::insert('processed_webhooks', $id);
creditOrder($event['order_id']);

Juntas, a verificação de assinatura e a idempotência oferecem uma integração de pagamento que é segura e resiliente. Veja o guia completo de integração para a referência completa de webhook da Payora.

Comments

Pronto para começar?

Crie uma conta e emita a sua primeira fatura em menos de uma hora.

O que esta página responde