Skip to Content
WebhooksVerificação de assinatura

Verificação de assinatura

Verifica sempre as assinaturas dos webhooks antes de processar o payload. Isto garante que o pedido é realmente do Ignite e não foi alterado.

Porquê verificar assinaturas?

Os endpoints de webhook são URLs publicamente acessíveis. Sem verificação de assinatura, qualquer pessoa poderia enviar eventos falsos para o teu endpoint. A verificação da assinatura garante:

  • Autenticidade — O pedido vem do Ignite
  • Integridade — O payload não foi modificado em trânsito

Formato da assinatura

Cada pedido de webhook inclui um cabeçalho X-Webhook-Signature no seguinte formato:

t=1705316400000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
ComponenteDescrição
tTimestamp Unix em milissegundos quando a assinatura foi gerada
v1Assinatura HMAC-SHA256 (codificada em hexadecimal)

Algoritmo de verificação

A assinatura é calculada sobre uma combinação do timestamp e do corpo do pedido. Há duas abordagens:

Opção A: Corpo em bruto (recomendado)

signed_payload = timestamp + "." + raw_body signature = HMAC-SHA256(signed_payload, signing_secret)

Opção B: JSON.stringify

signed_payload = timestamp + "." + JSON.stringify(body) signature = HMAC-SHA256(signed_payload, signing_secret)

O raw_body são os bytes exatos recebidos no pedido; JSON.stringify(body) volta a serializar o objeto JSON já analisado.

Abordagens de verificação

Há duas formas de verificar a assinatura. Escolhe a que se adequa ao teu caso:

AbordagemFiabilidadeComplexidadeUsar quando
Corpo em brutoGarantidaMaiorSistemas em produção, requisitos elevados de fiabilidade
JSON.stringifyMuito altaMenorIntegrações rápidas, quando o acesso ao corpo em bruto é difícil

Porquê a diferença?

A assinatura é calculada sobre os bytes exatos enviados pelo Ignite. Ao usar JSON.stringify() sobre o corpo já analisado:

  • Na maior parte dos casos funciona: JSON.stringify() é determinístico para o mesmo objeto JavaScript
  • Problemas possíveis: A ordenação das chaves em objetos aninhados, a formatação de números (1.0 vs 1) ou o escape Unicode podem, em teoria, diferir entre serializadores

Na prática, JSON.stringify() funciona de forma fiável porque o Ignite e o teu código usam serialização JSON standard. Contudo, o corpo em bruto dá uma garantia de 100%.


Opção A: Corpo em bruto (recomendado)

Esta abordagem usa os bytes exatos recebidos, garantindo que a assinatura corresponde sempre.

import { createHmac, timingSafeEqual } from "crypto"; import { NextApiRequest, NextApiResponse } from "next"; // Disable the default body parser to access raw body export const config = { api: { bodyParser: false, }, }; async function getRawBody(req: NextApiRequest): Promise<string> { const chunks: Buffer[] = []; for await (const chunk of req) { chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); } return Buffer.concat(chunks).toString('utf-8'); } function isValidSignature(rawBody: string, signatureHeader: string | string[] | undefined, secret: string): boolean { try { if (typeof signatureHeader !== "string") { return false; } const time = signatureHeader.match(/t=(\d+)/)?.[1]; const token = signatureHeader.match(/v1=(\w+)/)?.[1]; if (!time || !token) { return false; } const signedPayload = `${time}.${rawBody}`; const hmac = createHmac("sha256", secret); const calculatedSignature = hmac.update(signedPayload).digest("hex"); return timingSafeEqual(Buffer.from(token), Buffer.from(calculatedSignature)); } catch { return false; } } export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== "POST") { return res.status(405).end(); } const rawBody = await getRawBody(req); if (!isValidSignature(rawBody, req.headers["x-webhook-signature"], process.env.WEBHOOK_SIGNING_SECRET!)) { return res.status(403).end(); } const body = JSON.parse(rawBody); // Process the webhook... return res.status(200).json({ received: true }); }

Opção B: JSON.stringify (mais simples)

Esta abordagem volta a serializar o corpo já analisado. É mais simples, mas depende de serialização JSON consistente entre o Ignite e o teu código.

Na prática esta abordagem funciona de forma fiável porque ambos os lados usam JSON.stringify() standard sem opções de formatação. Usa-a quando o acesso ao corpo em bruto for incómodo ou precisares de uma integração rápida.

import { createHmac, timingSafeEqual } from "crypto"; import { NextApiRequest, NextApiResponse } from "next"; // Using the default body parser (no config export needed) function isValidSignature(req: NextApiRequest, secret: string): boolean { try { const signature = req.headers["x-webhook-signature"]; if (typeof signature !== "string") { return false; } const time = signature.match(/t=(\d+)/)?.[1]; const token = signature.match(/v1=(\w+)/)?.[1]; if (!time || !token) { return false; } const signedPayload = `${time}.${JSON.stringify(req.body)}`; const hmac = createHmac("sha256", secret); const calculatedSignature = hmac.update(signedPayload).digest("hex"); return timingSafeEqual(Buffer.from(token), Buffer.from(calculatedSignature)); } catch { return false; } } export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== "POST") { return res.status(405).end(); } if (!isValidSignature(req, process.env.WEBHOOK_SIGNING_SECRET!)) { return res.status(403).end(); } const body = req.body; // Process the webhook... return res.status(200).json({ received: true }); }

Validação do timestamp (opcional)

Para prevenir ataques de repetição, podes também validar que o timestamp é recente:

function isTimestampValid(signatureHeader: string, toleranceMs = 300000): boolean { const time = signatureHeader.match(/t=(\d+)/)?.[1]; if (!time) return false; const timestamp = parseInt(time, 10); const now = Date.now(); // Reject if timestamp is more than 5 minutes old (default) return Math.abs(now - timestamp) <= toleranceMs; }

Resolução de problemas

Assinatura não corresponde

Se a verificação da assinatura falhar, verifica o seguinte:

  1. Segredo correto — Confirma que estás a usar o signing secret da subscrição de webhook, e não um token de API
  2. Cabeçalho completo — Garante que estás a ler o cabeçalho X-Webhook-Signature na íntegra, incluindo as partes t= e v1=
  3. Integridade do corpo — Se usares a opção B, assegura-te de que nenhum middleware altera o corpo antes da verificação

Depuração

Adiciona logging para ajudar a diagnosticar problemas de assinatura:

function isValidSignature(rawBody, signatureHeader, secret) { const time = signatureHeader.match(/t=(\d+)/)?.[1]; const token = signatureHeader.match(/v1=(\w+)/)?.[1]; const signedPayload = `${time}.${rawBody}`; const calculatedSignature = crypto.createHmac("sha256", secret) .update(signedPayload) .digest("hex"); console.log('Received signature:', token); console.log('Calculated signature:', calculatedSignature); console.log('Payload length:', rawBody.length); console.log('First 100 chars:', rawBody.substring(0, 100)); return token === calculatedSignature; }