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ładowa | Opis |
|---|---|
t | Znacznik czasu Unix w milisekundach w momencie wygenerowania podpisu |
v1 | Podpis 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ście | Niezawodność | Złożoność | Kiedy używać |
|---|---|---|---|
| Surowe ciało | Gwarantowana | Wyższa | Systemy produkcyjne, wysokie wymagania co do niezawodności |
| JSON.stringify | Bardzo wysoka | Niższa | Szybkie 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.0vs1) 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.
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 });
}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.
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 });
}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ź:
- Właściwy sekret — używasz sekretu podpisywania z subskrypcji webhooka, a nie tokena API
- Pełny nagłówek — czytasz cały nagłówek
X-Webhook-Signature, włącznie z częściamit=iv1= - 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;
}