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| Componente | Descrição |
|---|---|
t | Timestamp Unix em milissegundos quando a assinatura foi gerada |
v1 | Assinatura 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:
| Abordagem | Fiabilidade | Complexidade | Usar quando |
|---|---|---|---|
| Corpo em bruto | Garantida | Maior | Sistemas em produção, requisitos elevados de fiabilidade |
| JSON.stringify | Muito alta | Menor | Integraçõ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.0vs1) 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.
Next.js API Routes
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.
Next.js API Routes
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:
- Segredo correto — Confirma que estás a usar o signing secret da subscrição de webhook, e não um token de API
- Cabeçalho completo — Garante que estás a ler o cabeçalho
X-Webhook-Signaturena íntegra, incluindo as partest=ev1= - 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;
}