Endpoint webhook tidak cukup hanya menerima POST lalu memproses JSON. Jika Anda membangun integrasi di Next.js App Router, Anda perlu memastikan payload benar-benar berasal dari provider yang sah, tidak diputar ulang oleh penyerang, dan tidak diproses dua kali saat provider melakukan retry. Kombinasi verifikasi signature, validasi timestamp, dan deduplikasi event adalah fondasi minimal untuk webhook yang aman.

Pada artikel ini, kita akan membangun pola implementasi verifikasi webhook aman dengan replay protection di Next.js. Fokusnya bukan pada provider tertentu, melainkan pendekatan yang bisa diadaptasi ke Stripe, GitHub, Slack, atau penyedia webhook internal selama mereka mengirim signature dan timestamp.

Ancaman utama pada endpoint webhook

Sebelum menulis kode, penting memahami apa yang sedang kita lindungi:

  • Payload palsu: siapa pun bisa melakukan POST ke endpoint publik Anda jika tidak ada verifikasi signature.
  • Replay attack: payload valid yang pernah dikirim provider direkam lalu dikirim ulang untuk memicu efek samping berulang.
  • Retry provider: provider sah bisa mengirim event yang sama beberapa kali saat timeout atau respons non-2xx.
  • Event out of order: urutan pengiriman event tidak selalu sama dengan urutan kejadian bisnis.
  • Kebocoran data sensitif di log: body mentah, secret, atau header signature sering tanpa sengaja tercatat di log.

Karena itu, endpoint webhook yang aman biasanya mengikuti urutan ini:

  1. Baca raw body apa adanya.
  2. Ambil header signature dan timestamp.
  3. Validasi timestamp dengan toleransi clock skew.
  4. Bangun ulang string yang ditandatangani lalu verifikasi HMAC dengan perbandingan konstan.
  5. Cek apakah event sudah pernah diproses.
  6. Proses event secara idempoten.
  7. Kembalikan respons HTTP yang tepat agar retry provider terkontrol.

Prinsip penting di Next.js App Router

Kenapa raw body wajib dipakai

Signature webhook hampir selalu dihitung dari body mentah, bukan hasil parsing JSON. Jika Anda memanggil await req.json() terlalu cepat, lalu membangun ulang string dengan JSON.stringify(), hasilnya bisa berbeda dari body asli karena whitespace, urutan field, atau encoding. Akibatnya signature valid akan terlihat salah.

Di route handler App Router, cara aman adalah membaca body sebagai text atau array buffer terlebih dahulu, misalnya dengan await req.text(). Setelah verifikasi selesai, barulah payload di-parse sebagai JSON.

Jebakan body parser

Pada banyak framework, body parser otomatis adalah sumber masalah umum dalam integrasi webhook. Di App Router, Anda punya kontrol lebih langsung atas objek Request, tetapi jebakannya tetap sama: jangan parse body sebelum verifikasi signature. Selain itu, jangan membaca body dua kali karena stream request biasanya hanya bisa dikonsumsi sekali.

Format verifikasi yang aman

Implementasi tiap provider berbeda, tetapi pola umumnya serupa:

  • Provider mengirim timestamp di header.
  • Provider mengirim signature HMAC dari kombinasi timestamp dan raw body.
  • Aplikasi Anda menghitung ulang HMAC dengan secret yang sama.
  • Hasil dihitung dibandingkan dengan signature dari header menggunakan perbandingan aman.

Contoh string yang ditandatangani sering berbentuk:

signedPayload = `${timestamp}.${rawBody}`

Jangan mengasumsikan format ini untuk semua provider. Selalu cek dokumentasi provider Anda. Namun pola implementasinya tetap relevan.

Replay protection dengan timestamp

Replay protection bekerja dengan menolak request yang timestamp-nya terlalu jauh dari waktu server saat ini. Misalnya, Anda menetapkan toleransi 5 menit. Jika request datang dengan timestamp lebih lama dari itu, request dianggap tidak valid meskipun signaturenya benar.

Ini penting karena penyerang bisa merekam request sah lalu mengirim ulang. Signature HMAC saja tidak cukup jika request lama masih diterima tanpa batas waktu.

Toleransi clock skew

Jangan gunakan validasi timestamp yang terlalu ketat. Jam provider dan server Anda bisa berbeda beberapa detik hingga menit. Praktik umum adalah memberi toleransi beberapa menit, misalnya 300 detik. Nilai terlalu kecil berisiko menolak request sah; terlalu besar memperlebar jendela replay.

Catatan: replay protection berbasis timestamp tidak menggantikan deduplikasi event. Keduanya diperlukan. Timestamp mencegah request lama diputar ulang, sedangkan deduplikasi mencegah event yang sama diproses lebih dari sekali saat provider melakukan retry.

Contoh route handler Next.js App Router

Berikut contoh generik route handler di app/api/webhooks/provider/route.ts. Contoh ini menggunakan HMAC SHA-256, membaca raw body dengan benar, memvalidasi timestamp, memeriksa signature, lalu menerapkan deduplikasi berbasis event ID.

import { createHmac, timingSafeEqual } from 'node:crypto';
import { NextResponse } from 'next/server';

const WEBHOOK_SECRET_CURRENT = process.env.WEBHOOK_SECRET_CURRENT || '';
const WEBHOOK_SECRET_PREVIOUS = process.env.WEBHOOK_SECRET_PREVIOUS || '';
const TIMESTAMP_TOLERANCE_SECONDS = 300;

function computeSignature(secret: string, signedPayload: string) {
  return createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex');
}

function safeCompareHex(a: string, b: string) {
  const aBuf = Buffer.from(a, 'hex');
  const bBuf = Buffer.from(b, 'hex');

  if (aBuf.length !== bBuf.length) return false;
  return timingSafeEqual(aBuf, bBuf);
}

function verifySignature({
  rawBody,
  timestamp,
  signature,
}: {
  rawBody: string;
  timestamp: string;
  signature: string;
}) {
  const signedPayload = `${timestamp}.${rawBody}`;
  const secrets = [WEBHOOK_SECRET_CURRENT, WEBHOOK_SECRET_PREVIOUS].filter(Boolean);

  for (const secret of secrets) {
    const expected = computeSignature(secret, signedPayload);
    if (safeCompareHex(expected, signature)) {
      return true;
    }
  }

  return false;
}

async function hasProcessedEvent(eventId: string): Promise {
  // Ganti dengan database/Redis Anda.
  return false;
}

async function markEventProcessed(eventId: string): Promise {
  // Simpan eventId dengan unique constraint atau atomic set-if-not-exists.
}

async function processEvent(event: any): Promise {
  switch (event.type) {
    case 'order.paid':
      // Terapkan logika bisnis idempoten.
      break;
    case 'subscription.cancelled':
      break;
    default:
      // Event tidak dikenal bisa diabaikan, tapi tetap catat secara aman.
      break;
  }
}

export async function POST(req: Request) {
  const signature = req.headers.get('x-provider-signature');
  const timestamp = req.headers.get('x-provider-timestamp');

  if (!signature || !timestamp) {
    return NextResponse.json({ error: 'missing signature headers' }, { status: 400 });
  }

  const timestampMs = Number(timestamp) * 1000;
  if (!Number.isFinite(timestampMs)) {
    return NextResponse.json({ error: 'invalid timestamp' }, { status: 400 });
  }

  const ageSeconds = Math.abs(Date.now() - timestampMs) / 1000;
  if (ageSeconds > TIMESTAMP_TOLERANCE_SECONDS) {
    return NextResponse.json({ error: 'timestamp outside tolerance' }, { status: 400 });
  }

  const rawBody = await req.text();

  const isValid = verifySignature({ rawBody, timestamp, signature });
  if (!isValid) {
    return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
  }

  let event: any;
  try {
    event = JSON.parse(rawBody);
  } catch {
    return NextResponse.json({ error: 'invalid json' }, { status: 400 });
  }

  const eventId = event?.id;
  if (!eventId || typeof eventId !== 'string') {
    return NextResponse.json({ error: 'missing event id' }, { status: 400 });
  }

  if (await hasProcessedEvent(eventId)) {
    return NextResponse.json({ received: true, duplicate: true }, { status: 200 });
  }

  try {
    await markEventProcessed(eventId);
    await processEvent(event);
  } catch (err) {
    // Jika markEventProcessed dilakukan terlalu awal, Anda perlu strategi kompensasi.
    // Alternatif yang lebih kuat: transaksi DB atau status processing/processed.
    console.error('webhook processing failed', {
      eventId,
      type: event?.type,
      message: err instanceof Error ? err.message : 'unknown',
    });

    return NextResponse.json({ error: 'processing failed' }, { status: 500 });
  }

  return NextResponse.json({ received: true }, { status: 200 });
}

Kenapa urutannya seperti ini

  • Header dicek lebih dulu agar request yang jelas invalid cepat ditolak.
  • Timestamp divalidasi sebelum parsing JSON untuk menolak replay lebih awal.
  • Raw body dibaca sekali lalu dipakai untuk signature dan parsing.
  • Signature diverifikasi sebelum proses bisnis agar data palsu tidak pernah menyentuh logika inti.
  • Event ID dicek untuk mencegah duplikasi akibat retry.

Deduplikasi event yang aman

Provider webhook umumnya menerapkan retry jika endpoint lambat, timeout, atau mengembalikan 4xx/5xx tertentu. Karena itu, memproses event secara idempoten lebih penting daripada berharap provider hanya mengirim sekali.

Pilihan penyimpanan event ID

Ada dua strategi umum:

  • Database relasional dengan unique constraint: cocok jika Anda sudah memakai PostgreSQL/MySQL dan ingin konsistensi kuat.
  • Redis dengan atomic set-if-not-exists dan TTL: cocok untuk deduplikasi cepat dengan retensi terbatas.

Strategi dengan database

Buat tabel seperti webhook_events dengan kolom:

  • event_id sebagai unique key
  • provider
  • status seperti processing, processed, failed
  • received_at
  • processed_at

Keuntungannya adalah Anda bisa melacak lifecycle event dan memproses ulang event gagal secara terkontrol. Pendekatan ini lebih kuat daripada hanya menyimpan daftar ID di memori, yang akan hilang saat restart atau scale-out.

Strategi dengan Redis

Jika kebutuhan Anda sederhana, simpan key seperti webhook:event:{eventId} menggunakan operasi atomik seperti set-if-not-exists. Tambahkan TTL sesuai kebutuhan retensi, misalnya beberapa hari. Ini efektif untuk mencegah duplikasi akibat retry jangka pendek, tetapi kurang ideal jika Anda butuh audit lengkap.

Jangan gunakan in-memory map untuk deduplikasi di production. Pada deployment serverless atau multi-instance, request bisa masuk ke instance berbeda dan duplikasi tetap lolos.

Masalah race condition saat deduplikasi

Jika dua request identik masuk hampir bersamaan, pola check lalu insert bisa kalah balapan. Solusinya adalah memakai operasi atomik:

  • DB: insert dengan unique constraint, lalu anggap konflik unique sebagai duplikat.
  • Redis: gunakan operasi atomik set-if-not-exists.

Ini lebih aman daripada memanggil hasProcessedEvent() lalu markEventProcessed() secara terpisah tanpa penguncian.

Respons HTTP yang tepat

Pemilihan status code memengaruhi perilaku retry provider. Walau tiap provider punya aturan sendiri, panduan praktis berikut aman secara umum:

  • 200/204: request valid dan sudah diterima. Gunakan juga untuk event duplikat yang tidak perlu diproses lagi.
  • 400: header wajib hilang, timestamp invalid, JSON rusak, atau payload malformed.
  • 401/403: signature tidak valid atau secret salah.
  • 500: request valid tetapi pemrosesan internal gagal dan Anda ingin provider mencoba lagi.

Untuk event duplikat, biasanya lebih baik mengembalikan 200 daripada 409. Tujuannya adalah memberi tahu provider bahwa event tersebut tidak perlu dikirim ulang.

Kapan sebaiknya ack cepat

Jika proses bisnis berat, pertimbangkan pola berikut:

  1. Verifikasi signature dan timestamp.
  2. Simpan event secara durable.
  3. Masukkan ke queue.
  4. Segera balas 200.
  5. Proses event secara asynchronous.

Pola ini mengurangi timeout dan retry yang tidak perlu. Namun, tetap pastikan queue consumer Anda juga idempoten, karena duplikasi masih mungkin terjadi.

Rotasi secret tanpa downtime

Rotasi secret adalah jebakan umum. Saat provider mulai memakai secret baru, sebagian request bisa masih ditandatangani dengan secret lama untuk sementara. Jika endpoint Anda hanya mengenal satu secret, integrasi bisa putus di tengah transisi.

Solusi praktisnya adalah mendukung dua secret aktif sementara:

  • WEBHOOK_SECRET_CURRENT
  • WEBHOOK_SECRET_PREVIOUS

Verifikasi signature terhadap keduanya. Setelah semua traffic diyakini sudah pindah ke secret baru, hapus secret lama. Ini sederhana dan cukup aman selama transisi dibatasi waktunya.

Jangan pernah menuliskan secret ke log, termasuk saat debugging. Jika perlu mencatat sumber verifikasi, cukup log apakah signature cocok dengan secret aktif atau secret sebelumnya tanpa menuliskan nilainya.

Urutan event tidak terjamin

Salah satu asumsi yang sering salah adalah mengira event datang berurutan. Dalam kenyataannya, provider bisa mengirim:

  • event A lalu event B, tetapi B tiba lebih dulu, atau
  • event yang lebih lama di-retry setelah event baru sudah diproses.

Karena itu, jangan menulis logika yang bergantung pada urutan arrival request. Lebih aman jika:

  • state akhir diambil dari sumber kebenaran di database Anda,
  • setiap event memeriksa versi/status saat ini sebelum update,
  • untuk event penting, lakukan fetch ulang ke API provider jika state lokal meragukan.

Contoh: jika menerima subscription.cancelled sebelum subscription.created, jangan otomatis gagal total. Simpan event, tandai pending, atau rekonsiliasi dengan API provider jika diperlukan.

Logging yang berguna tanpa membocorkan data sensitif

Logging penting untuk debug webhook, tetapi endpoint ini mudah membocorkan informasi sensitif. Praktik yang aman:

  • Log event ID, event type, waktu terima, dan hasil verifikasi.
  • Jangan log raw body penuh jika payload berisi data pelanggan.
  • Jangan log header signature atau secret.
  • Jika perlu korelasi, log hash pendek dari body, bukan isi aslinya.

Contoh log yang cukup aman:

console.info('webhook received', {
  provider: 'provider-x',
  eventId,
  eventType: event?.type,
  verified: true,
  duplicate: false,
});

Kesalahan umum yang sering menyebabkan signature mismatch

  • Menggunakan req.json() sebelum verifikasi.
  • Menggunakan JSON.stringify() pada objek hasil parse untuk menghitung ulang signature.
  • Salah format string yang ditandatangani, misalnya lupa menambahkan timestamp atau separator.
  • Salah encoding saat menghitung HMAC.
  • Mengambil header yang salah atau nama header berbeda huruf/format dari dokumentasi provider.
  • Mencampur secret production dan development.
  • Timestamp provider dalam milidetik, tetapi kode menganggap detik, atau sebaliknya.

Tips debugging signature mismatch

  1. Pastikan raw body dibaca sekali dengan req.text().
  2. Log panjang body, bukan isinya, untuk memastikan request tidak kosong.
  3. Log timestamp yang diterima dan umur request dalam detik.
  4. Pastikan environment variable secret benar-benar termuat.
  5. Bandingkan implementasi string-to-sign dengan dokumentasi provider.
  6. Uji dengan payload kecil yang Anda tandatangani sendiri secara lokal.

Checklist pengujian lokal

Sebelum menghubungkan ke provider sungguhan, uji endpoint dengan payload palsu yang ditandatangani sendiri. Ini membantu memastikan implementasi HMAC, timestamp, dan deduplikasi sudah benar.

Script sederhana untuk membuat signature lokal

node -e "const crypto=require('node:crypto'); const secret=process.env.WEBHOOK_SECRET_CURRENT; const body=JSON.stringify({id:'evt_123',type:'order.paid'}); const ts=Math.floor(Date.now()/1000).toString(); const sig=crypto.createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex'); console.log({ts,sig,body});"

Lalu kirim dengan curl:

curl -i -X POST http://localhost:3000/api/webhooks/provider \
  -H 'Content-Type: application/json' \
  -H 'x-provider-timestamp: 1710000000' \
  -H 'x-provider-signature: <signature>' \
  --data '{"id":"evt_123","type":"order.paid"}'

Skenario uji yang sebaiknya dilakukan

  1. Request valid: timestamp masih dalam toleransi, signature benar, event baru.
  2. Signature salah: ubah satu karakter body setelah signature dibuat.
  3. Timestamp kedaluwarsa: pakai timestamp lama di luar toleransi.
  4. Clock skew kecil: uji timestamp sedikit maju atau mundur untuk memastikan toleransi bekerja.
  5. Event duplikat: kirim event ID yang sama dua kali dan pastikan request kedua dibalas 200 tanpa efek samping ganda.
  6. Retry karena kegagalan internal: paksa processEvent() melempar error dan lihat apakah provider atau script penguji mencoba lagi.
  7. Body tidak valid: kirim JSON rusak.
  8. Header hilang: kirim tanpa signature atau tanpa timestamp.
  9. Rotasi secret: uji request yang ditandatangani dengan secret lama dan baru.

Rekomendasi implementasi production

Untuk banyak sistem, pendekatan yang paling tahan terhadap masalah operasional adalah:

  1. Verifikasi signature dari raw body.
  2. Validasi timestamp dengan toleransi beberapa menit.
  3. Simpan event ke storage durable dengan unique constraint pada event ID.
  4. Balas 200 secepat mungkin setelah event berhasil direkam.
  5. Proses event melalui worker atau queue secara idempoten.
  6. Audit log hanya metadata yang aman.

Pendekatan ini memisahkan keamanan ingress webhook dari logika bisnis yang mungkin lebih berat atau lebih rentan gagal.

Penutup

Membangun verifikasi webhook aman di Next.js bukan hanya soal menghitung HMAC. Anda juga perlu memastikan body mentah tidak berubah, timestamp divalidasi untuk replay protection, event yang sama tidak diproses dua kali, dan respons HTTP mendorong perilaku retry yang benar dari provider. Jika Anda menerapkan empat hal ini dengan disiplin, sebagian besar masalah integrasi webhook di production bisa dihindari sejak awal.