Destino Dev

Blog

Webhooks seguros no Laravel: HMAC, idempotência e teste com ngrok

Por John Pires · 19 de março de 2026

Webhooks seguros no Laravel: HMAC, idempotência e teste com ngrok

Contexto: quando você vai usar isso

Webhooks são callbacks HTTP enviados por serviços externos para notificar seu sistema sobre eventos: pagamento confirmado, arquivo processado, usuário atualizado. Em vez de consultar a API do provedor repetidamente, você abre uma porta (endpoint) e recebe notificações.

Receber webhooks é simples na prática, mas inseguro por padrão. Qualquer pessoa pode descobrir uma URL pública e disparar requisições. Além disso, webhooks podem ser entregues múltiplas vezes — seu serviço precisa ser idempotente. Se você estiver usando Laravel para construir APIs ou sistemas que consomem eventos de terceiros (Stripe, Mailgun, GitHub, etc.), precisa de três níveis mínimos de controle: autenticação/assinatura (HMAC), proteção contra entregas duplicadas (idempotência) e um modo seguro para testar localmente (ngrok).

Quando implementar

Implemente HMAC quando o provedor suportar assinaturas; implemente idempotência sempre que a ação disparada tiver efeitos colaterais (criar pedido, creditar usuário). Use ngrok durante desenvolvimento para validar o fluxo sem expor código em produção.

Riscos que você evita

  • Execução não autorizada de endpoints
  • Duplicação de operações causadas por reentregas
  • Exposição de dados sensíveis durante testes locais

Explicação visual com diagramas mentais

Aqui estão dois diagramas mentais simples que ajudam a relacionar os componentes. Não são arte ASCII bonita, são mapas mentais práticos para lembrar o fluxo.

Provedor -> (1) POST /webhook -> (2) Middleware HMAC -> (3) Controller -> (4) Job/Service -> Banco/Idempotency Table

Passos:

  1. Provedor envia POST assinada com HMAC
  2. Middleware valida assinatura com secret compartilhado
  3. Controller extrai idempotency-key e ignora entregas repetidas
  4. Job grava efeitos no banco e responde 200

Componentes principais

O diagrama destaca: secret de HMAC, header com assinatura, optional header idempotency-key, tabela para registrar chaves de idempotência, e resposta rápida (idealmente 200/202).

Por que validar a assinatura no middleware?

Separar a validação em middleware mantém controllers limpos e garante que nenhum controller execute lógica quando a assinatura for inválida. Também permite reuso entre múltiplos endpoints de webhook.

Primeiro exemplo simples

Vamos implementar um pipeline mínimo: rota, middleware que verifica HMAC, controller que processa e marca idempotência. Código escrito para Laravel 10+ e PHP 8.3+.

1) Configurar secret

Coloque o secret do provedor em .env (exemplo: WEBHOOK_SECRET_PROVEDOR). Nunca comite esse valor.

WEBHOOK_SECRET_PROVEDOR=supersegredodo_provedor_1234567890

Esse é o valor que o provedor e você compartilham. O provedor usa esse secret para criar o HMAC do payload e envio num header como X-Signature ou X-Hub-Signature.

2) Middleware de verificação HMAC

O middleware valida o header de assinatura comparando com o HMAC do payload. A comparação precisa ser tempo-constante para evitar ataques por tempo.

Exemplo de middleware: VerifyWebhookSignature. Coloque em app/Http/Middleware/VerifyWebhookSignature.php.

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyWebhookSignature
{
    private string $secret;

    public function __construct()
    {
        $this->secret = config('services.webhook.secret');
    }

    public function handle(Request $request, Closure $next): Response
    {
        $signatureHeader = $request->header('x-signature');
        $payload = (string) $request->getContent();

        if (! $signatureHeader || ! $this->isValidSignature($payload, $signatureHeader)) {
            return response('Invalid signature', 401);
        }

        return $next($request);
    }

    private function isValidSignature(string $payload, string $signatureHeader): bool
    {
        // Provedor envia hex HMAC SHA256
        $expected = hash_hmac('sha256', $payload, $this->secret);

        // hash_equals é tempo-constante; importante para segurança
        return hash_equals($expected, $signatureHeader);
    }
}

O middleware lê o payload bruto e compara a assinatura. Se inválida retorna 401 e aborta o pipeline.

3) Rota e controller mínimo

Rota: routes/api.php

use App\Http\Controllers\WebhookController;

Route::post('/webhook/provedor', [WebhookController::class, 'handle'])
    ->middleware('verify.webhook');

Controller que demonstra idempotência básica via header X-Idempotency-Key. Se o header não existir, você pode gerar hash do payload.

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;

class WebhookController extends Controller
{
    public function handle(Request $request)
    {
        $key = $request->header('x-idempotency-key') ?: sha1($request->getContent());

        // Tenta inserir registro de idempotencia; se já existe, ignora
        try {
            DB::table('webhook_idempotency')->insert([
                'key' => $key,
                'payload' => $request->getContent(),
                'created_at' => now(),
            ]);
        } catch (\Exception $e) {
            // Se falhar por duplicidade, responder 200 sem reprocessar
            return response('Already processed', 200);
        }

        // Processa payload (simulação)
        $data = $request->json()->all();
        // ... lógica: criar pedido, atualizar usuário, etc.

        return response('OK', 200);
    }
}

Depois de inserir a chave de idempotência, o controller processa o payload normalmente. Se a inserção falhar por duplicidade (conforme a constraint da tabela), devolve 200 sem reprocessar.

Segundo exemplo mais realista

Neste exemplo vamos detalhar a tabela de idempotência, tratamento de jobs e resposta rápida (202 Accepted) quando o processamento for assíncrono. Também incluiremos um script de envio PHP que emula o provedor e assina o payload com HMAC para facilitar testes.

Tabela de idempotência

Migração para webhook_idempotency. Use constraint única na coluna key para garantir atomicidade.

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('webhook_idempotency', function (Blueprint $table) {
            $table->id();
            $table->string('key')->unique();
            $table->text('payload');
            $table->timestamp('created_at')->useCurrent();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('webhook_idempotency');
    }
};

Ao usar unique(), uma segunda tentativa de inserir a mesma chave falhará rapidamente com exceção de duplicidade — que você deve capturar e tratar.

Processamento assíncrono

O controller pode despachar um job para fila e responder 202 imediatamente. Isso melhora latência para o remetente do webhook e evita timeouts.

use App\Jobs\ProcessWebhookEvent;
use Illuminate\Http\Request;

public function handle(Request $request)
{
    $key = $request->header('x-idempotency-key') ?: sha1($request->getContent());

    try {
        DB::table('webhook_idempotency')->insert([
            'key' => $key,
            'payload' => $request->getContent(),
            'created_at' => now(),
        ]);
    } catch (\Exception $e) {
        return response('Already processed', 200);
    }

    // Despacha job assíncrono
    ProcessWebhookEvent::dispatch($request->json()->all(), $key);

    return response(null, 202);
}

O job ProcessWebhookEvent realiza a lógica de negócio: valida campos, grava registros e, se necessário, atualiza o registro de idempotência com status.

Script PHP que emula o provedor (envia webhook assinado)

Um script simples que envia POST para sua URL pública do ngrok assina o payload com HMAC SHA256 usando o secret. Útil para testes locais e CI.

<?php
// send_webhook.php
$secret = getenv('WEBHOOK_SECRET_PROVEDOR');
$payload = json_encode(['event' => 'order.paid', 'order_id' => 12345]);
$signature = hash_hmac('sha256', $payload, $secret);

$ch = curl_init('https://seu-subdominio.ngrok.io/api/webhook/provedor');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json',
    'x-signature: ' . $signature,
    'x-idempotency-key: order-12345',
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

echo "HTTP: $http\n";
echo "Response: $response\n";

Esse script demonstra exatamente como um provedor deveria assinar payloads e enviar um header de idempotência. No seu ambiente local, aponte para a URL pública do ngrok que encaminha para localhost.

Erros comuns de iniciante e como evitar

Erros de segurança e confiabilidade são frequentes quando se começa a lidar com webhooks. Abaixo os mais comuns e correções práticas.

Assinar o payload errado

Muitos comparam a assinatura com o corpo JSON ao invés do payload bruto. Alterações de encoding ou espaços podem alterar o hash. Sempre gere o HMAC sobre o payload bruto (getContent()).

Use $request->getContent() para HMAC, não $request->input() ou json_decode/encode.

Além disso, confirme qual algoritmo o provedor usa (sha256, sha1) e se a assinatura vem em hex ou base64.

Não usar comparação tempo-constante

Comparar strings com == permite ataques por tempo. Use hash_equals para evitar vazamento de timing. O middleware de exemplo usa hash_equals corretamente.

Fazer processamento pesado na thread da requisição

Executar operações demoradas na requisição do webhook (chamadas externas, cálculos longs, etc.) causa timeouts e reenvios automáticos pelo provedor. Sempre offload para jobs e responda 200/202 rapidamente.

Idempotência mal implementada

Erros comuns:

  • Usar um endpoint sem requerer chave de idempotência
  • Gravar estado antes de verificar duplicidade
  • Não usar constraint única no banco para garantir atomicidade

Correção: exija ou derive uma chave idempotência, insira com unique constraint e trate a exceção de duplicidade para evitar reprocessamento.

Testes locais sem ngrok seguro

Alguns tentam abrir portas no roteador ou expor diretamente a máquina de desenvolvimento. Isso é arriscado. Use ngrok para criar túnel TLS e controle o acesso via tokens. Nunca deixe URLs ngrok públicas em repositórios.

Como testar com ngrok — passos práticos

1) Instale ngrok e autentique com seu token: ngrok authtoken

2) Rode ngrok apontando para a porta do Laravel (ex: 8000):

ngrok http 8000

3) ngrok mostrará uma URL pública HTTPS. Atualize o dashboard do provedor com essa URL + /api/webhook/provedor.

4) Use o script PHP de envio (send_webhook.php) substituindo a URL do ngrok. Isso envia uma requisição assinada idêntica à do provedor real.

Mantenha o túnel ativo apenas durante testes e revogue o URL atualizado no provedor depois do uso.

Conclusão prática

Receber webhooks com segurança envolve três pilares: autenticação do remetente (HMAC), tolerância a reentregas (idempotência) e testes controlados (ngrok). O padrão recomendado no Laravel é validar assinatura em middleware, registrar uma chave de idempotência com constraint única e despachar jobs assíncronos para processamento.

Checklist mínimo para colocar em produção:

  • Secret seguro no servidor (env + vault)
  • Middleware que valida HMAC com hash_equals
  • Tabela de idempotência com unique key
  • Jobs para processamento assíncrono; responda 200/202
  • Testes automatizados que emulam o provedor, incluindo assinaturas

Seguindo esse fluxo você reduz vetores de abuso, evita duplicidade de operações e mantém ambiente de desenvolvimento seguro usando ngrok. Implementações adicionais podem incluir rate limiting para webhooks, logs estruturados e métricas de entrega para monitorar falhas do provedor.

Aplicar essas práticas desde cedo economiza horas de debugging quando sistemas reais começarem a enviar centenas de eventos. Comece pequeno (middleware + idempotência) e adicione robustez (jobs, retries, observability) conforme o volume aumenta.

Assuntos: PHP, Laravel, webhooks, hmac, ngrok, idempotencia

Leia também