Skip to Content
WebhooksWeryfikacja podpisu

Weryfikacja podpisu

Zawsze weryfikuj podpis webhooka zanim przetworzysz treść żądania. Dzięki temu masz pewność, że żądanie naprawdę pochodzi z Ignite i nie zostało zmienione w drodze.

Po co weryfikować podpisy?

Endpointy webhooków to publicznie dostępne adresy URL. Bez weryfikacji podpisu ktokolwiek mógłby wysyłać fałszywe zdarzenia na Twój endpoint. Weryfikacja podpisu zapewnia:

  • Autentyczność — żądanie pochodzi z Ignite
  • Integralność — treść nie została zmodyfikowana podczas przesyłu

Format podpisu

Każde żądanie webhooka zawiera nagłówek X-Webhook-Signature w następującym formacie:

t=1705316400000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
SkładowaOpis
tZnacznik czasu Unix w milisekundach w momencie wygenerowania podpisu
v1Podpis HMAC-SHA256 (zakodowany szesnastkowo)

Algorytm weryfikacji

Podpis liczony jest z połączenia znacznika czasu i treści żądania. Są dwa podejścia:

Opcja A: surowe ciało (zalecane)

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

Opcja B: JSON.stringify

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

raw_body to dokładnie bajty odebrane z żądania, a JSON.stringify(body) to ponowna serializacja sparsowanego obiektu JSON.

Podejścia do weryfikacji

Możesz zweryfikować podpis na dwa sposoby — wybierz ten, który pasuje do Twojego przypadku:

PodejścieNiezawodnośćZłożonośćKiedy używać
Surowe ciałoGwarantowanaWyższaSystemy produkcyjne, wysokie wymagania co do niezawodności
JSON.stringifyBardzo wysokaNiższaSzybkie integracje, gdy trudno dostać się do surowego ciała

Dlaczego jest różnica?

Podpis liczony jest z dokładnych bajtów wysłanych przez Ignite. Przy użyciu JSON.stringify() na sparsowanym ciele:

  • Zwykle działa: JSON.stringify() jest deterministyczny dla tego samego obiektu JavaScript
  • Potencjalne problemy: kolejność kluczy w zagnieżdżonych obiektach, format liczb (1.0 vs 1) lub escapowanie Unicode mogłyby teoretycznie różnić się między serializatorami

W praktyce JSON.stringify() działa niezawodnie, bo i Ignite, i Twój kod używają standardowej serializacji JSON. Surowe ciało daje jednak 100% gwarancji zgodności.


Opcja A: surowe ciało (zalecane)

To podejście używa dokładnie odebranych bajtów, więc podpis zawsze się zgadza.

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 }); }

Opcja B: JSON.stringify (prostsze)

To podejście ponownie serializuje sparsowane ciało. Jest prostsze, ale zakłada spójną serializację JSON między Ignite a Twoim kodem.

W praktyce to podejście działa niezawodnie, bo obie strony używają standardowego JSON.stringify() bez opcji formatowania. Stosuj je, gdy dostęp do surowego ciała jest niewygodny albo potrzebujesz szybkiej integracji.

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 }); }

Walidacja znacznika czasu (opcjonalnie)

Żeby utrudnić ataki powtórzeniowe, możesz dodatkowo sprawdzić, czy znacznik czasu jest świeży:

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; }

Rozwiązywanie problemów

Niezgodność podpisu

Jeśli weryfikacja się nie udaje, sprawdź:

  1. Właściwy sekret — używasz sekretu podpisywania z subskrypcji webhooka, a nie tokena API
  2. Pełny nagłówek — czytasz cały nagłówek X-Webhook-Signature, włącznie z częściami t= i v1=
  3. Nienaruszone ciało — przy opcji B upewnij się, że żadne middleware nie zmienia ciała przed weryfikacją

Debugowanie

Dodaj logowanie, żeby zdiagnozować problemy z podpisem:

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; }