Verifikasi signature webhook merupakan mekanisme keamanan utama untuk memastikan bahwa data yang masuk ke endpoint benar-benar berasal dari pihak ketiga (misalnya Stripe, GitHub, atau Midtrans) dan isinya belum dimanipulasi di tengah jalan. Pada Next.js App Router, implementasi verifikasi HMAC SHA-256 sering kali gagal secara misterius dengan status signature mismatch.
Akar masalah paling umum terletak pada cara aplikasi membaca data request. Artikel ini membahas cara menangani verifikasi webhook secara benar di Route Handler Next.js, mulai dari ekstraksi raw body, pencegahan timing attack, penanganan replay attack, hingga arsitektur idempotensi.
Akar Masalah: Mutasi Payload oleh req.json()
Penyedia webhook menghitung Hash-based Message Authentication Code (HMAC) menggunakan payload mentah (raw bytes) yang tepat sama dengan yang dikirim melalui jaringan HTTP. Signature dihitung dengan rumus:
Signature = HMAC-SHA256(Signing_Secret, Raw_Body_Payload)Kesalahan fatal yang sering dilakukan pada Route Handler Next.js adalah mengekstrak body menggunakan await req.json(), kemudian melakukan JSON.stringify() saat menguji signature:
// SALAH: Menyebabkan kegagalan verifikasi HMAC
const body = await req.json();
const signature = crypto
.createHmac('sha256', SECRET)
.update(JSON.stringify(body))
.digest('hex');Pendekatan di atas memicu inkonsistensi hash karena deserialisasi ke objek JavaScript dan serialisasi ulang ke JSON mengubah representasi biner data aslinya. Beberapa penyebab mutasi meliputi:
- Whitespace dan Indentasi: Perbedaan karakter spasi, tab, atau
\nyang dibuang oleh parser. - Urutan Key Objek: V8 engine tidak menjamin urutan properti JSON stringified sama persis dengan urutan raw string dari server pengirim.
- Format Angka: Nilai presisi desimal atau scientific notation (contoh:
10.00vs10) sering berubah representasinya setelah diparsing. - Escaping Karakter: Karakter Unicode atau backslash dapat mengalami normalisasi yang berbeda.
Solusi: Ekstraksi Raw Body via req.text()
Di Next.js App Router, req adalah instance standar Web API Request. Untuk mempertahankan integritas payload tanpa mutasi satu byte pun, ambil body sebagai teks mentah menggunakan req.text() atau buffer biner via req.arrayBuffer() sebelum parsing JSON dilakukan.
// BENAR: Membaca byte mentah sebelum deserialisasi
const rawBody = await req.text();
const calculatedSignature = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET!)
.update(rawBody)
.digest('hex');
// Parsing data hanya setelah signature terbukti valid
const payload = JSON.parse(rawBody);Catatan: Stream request di Route Handler hanya dapat dibaca satu kali. Jika Anda memanggilawait req.json()terlebih dahulu, pemanggilanawait req.text()berikutnya akan melempar error body stream already read.
Keamanan Signature: Mencegah Timing Attack
Operator komparasi bawaan JavaScript (=== atau ==) rentan terhadap timing attack. Operator tersebut membandingkan string karakter demi karakter dari kiri ke kanan dan langsung berhenti (early exit) begitu menemukan karakter pertama yang tidak cocok.
Penyerang dapat mengukur deviasi latensi respons jaringan hingga tingkat mikrodetik untuk menebak signature yang valid byte demi byte. Gunakan crypto.timingSafeEqual dari Node.js untuk mengeksekusi komparasi dalam durasi konstan (constant-time).
import crypto from 'node:crypto';
function verifyHmac(receivedSignature: string, calculatedSignature: string): boolean {
const receivedBuffer = Buffer.from(receivedSignature, 'hex');
const calculatedBuffer = Buffer.from(calculatedSignature, 'hex');
// timingSafeEqual wajib menerima buffer dengan panjang byte yang identik
if (receivedBuffer.length !== calculatedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(receivedBuffer, calculatedBuffer);
}Proteksi Replay Attack dan Idempotensi
1. Validasi Timestamp (Replay Attack Protection)
Penyerang yang berhasil menyadap request webhook valid dapat mengirim ulang (replay) payload tersebut ke server untuk memicu aksi ganda. Mayoritas penyedia webhook modern menyertakan header timestamp (misalnya X-Webhook-Timestamp atau bagian dari format signature Stripe t=1614555...).
Periksa usia timestamp terhadap jam server lokal. Toleransi waktu standar yang disarankan adalah 5 menit (300 detik) untuk mengatasi clock skew:
const toleranceInSeconds = 300;
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - Number(timestampHeader)) > toleranceInSeconds) {
return new Response('Payload timestamp expired', { status: 400 });
}2. Idempotensi via Event ID Deduplication
Jaringan HTTP memiliki sifat at-least-once delivery. Penyedia webhook akan melakukan retry otomatis jika koneksi timeout. Anda harus mencegah duplicate processing (misalnya pengiriman saldo dua kali) dengan mencatat identitas event unik (event_id) ke storage transaksional (seperti Redis atau database) menggunakan operasi atomik:
- Gunakan perintah
SET key value NX EX 86400di Redis (hanya set jika belum ada, dengan TTL 24 jam). - Jika key sudah ada, tandai request sebagai duplikat dan langsung kembalikan HTTP 200 tanpa mengeksekusi ulang logic bisnis.
Implementasi Lengkap Route Handler Siap Produksi
Simpan kode berikut pada path Route Handler Next.js di app/api/webhook/route.ts. Kode ini memverifikasi signature, mencegah replay attack, mengecek duplikasi, dan mengembalikan HTTP 200 sebelum pemrosesan asynchronous intensif berjalan.
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'node:crypto';
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;
const MAX_AGE_SECONDS = 300; // 5 menit
// Mock idempotency store (gunakan Redis/DB pada production)
const processedEvents = new Set<string>();
export async function POST(req: NextRequest) {
try {
// 1. Ekstraksi header keamanan
const signature = req.headers.get('x-signature');
const timestamp = req.headers.get('x-timestamp');
const eventId = req.headers.get('x-event-id');
if (!signature || !timestamp || !eventId) {
return NextResponse.json(
{ error: 'Missing security headers' },
{ status: 400 }
);
}
// 2. Mencegah Replay Attack: Validasi Timestamp
const now = Math.floor(Date.now() / 1000);
const requestTime = parseInt(timestamp, 10);
if (Number.isNaN(requestTime) || Math.abs(now - requestTime) > MAX_AGE_SECONDS) {
return NextResponse.json(
{ error: 'Request timestamp out of tolerance window' },
{ status: 400 }
);
}
// 3. Ekstraksi Raw Body mentah
const rawBody = await req.text();
// 4. Hitung HMAC SHA-256 lokal
// Pola umum: HMAC mencakup kombinasi timestamp dan rawBody
const signaturePayload = `${timestamp}.${rawBody}`;
const computedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signaturePayload)
.digest('hex');
// 5. Komparasi Constant-Time
const signatureBuffer = Buffer.from(signature, 'hex');
const computedBuffer = Buffer.from(computedSignature, 'hex');
if (
signatureBuffer.length !== computedBuffer.length ||
!crypto.timingSafeEqual(signatureBuffer, computedBuffer)
) {
return NextResponse.json(
{ error: 'Invalid HMAC signature' },
{ status: 401 }
);
}
// 6. Cek Idempotensi (Deduplikasi)
if (processedEvents.has(eventId)) {
// Return 200 agar provider tidak terus me-retry event yang sudah sukses
return NextResponse.json({ status: 'ignored_duplicate' }, { status: 200 });
}
processedEvents.add(eventId);
// 7. Parsing data payload terverifikasi
const payload = JSON.parse(rawBody);
// 8. Pemrosesan Asinkronus
// Di serverless, hindari operasi berat blocking yang menahan return HTTP 200
queueBackgroundWorker(payload);
return NextResponse.json({ status: 'acknowledged' }, { status: 200 });
} catch (error) {
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
);
}
}
function queueBackgroundWorker(data: unknown) {
// Kirim data ke message broker (AWS SQS, Redis BullMQ, Upstash QStash, dll)
// Pastikan respons 200 kembali dalam < 2 detik ke server pengirim webhook
}Checklist Debugging Masalah Signature
Jika verifikasi signature HMAC masih gagal di Route Handler Anda, periksa poin-poin krusial berikut:
- Encoding Digest: Periksa apakah provider mengirimkan signature dalam format
hexataubase64. Buffer komparasi harus didekode menggunakan format encoding yang sesuai. - Prefix Signature: Beberapa layanan menyertakan prefix skema seperti
sha256=abcdef...atauv1,abcdef.... Bersihkan prefix tersebut sebelum dimasukkan keBuffer.from(). - Environment Secret: Pastikan
process.env.WEBHOOK_SECRETtidak mengandung whitespace tersembunyi atau terpotong tanda kutip saat didefinisikan di platform deployment seperti Vercel. - Bypass Reverse Proxy Mutation: Jika menggunakan reverse proxy kustom (misalnya NGINX di depan Next.js), pastikan proxy tidak melakukan dekompresi gzip otomatis atau manipulasi whitespace pada body HTTP.
Ringkasan
Integritas webhook di Next.js App Router bergantung sepenuhnya pada penanganan raw body secara presisi. Jangan pernah memverifikasi hash dari objek hasil req.json(). Gunakan req.text() untuk komputasi hash, mitigasi timing attack dengan crypto.timingSafeEqual, validasi kesegaran timestamp untuk menolak replay attack, dan terapkan deduplikasi event ID demi memastikan eksekusi yang aman serta idempoten.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!