Kasus debug Nuxt Nitro ini bermula dari endpoint webhook yang menerima request dari provider, tetapi selalu membalas 400 atau 401 signature mismatch. Log menampilkan JSON yang valid, secret sudah diperiksa, dan algoritma HMAC terlihat benar. Masalahnya ternyata bukan pada isi JSON secara semantik, melainkan pada byte yang digunakan saat menghitung signature.

Verifikasi webhook harus dilakukan terhadap raw body persis seperti yang dikirim provider. Jika middleware lebih dahulu memanggil parser, membaca stream request secara langsung, atau melakukan JSON.stringify() terhadap object hasil parsing, byte payload dapat berubah atau tidak lagi tersedia. Solusinya adalah membaca raw body satu kali dengan readRawBody, memverifikasi signature sebelum parsing, dan menyimpan hasilnya di event.context jika beberapa lapisan aplikasi membutuhkannya.

Gejala signature mismatch yang menyesatkan

Route webhook berada di server/api/webhooks/provider.post.ts. Provider melaporkan request berhasil dikirim, tetapi aplikasi memberikan respons gagal dengan pola berikut:

  • Header signature tersedia dan secret sesuai dengan konfigurasi provider.
  • Body yang dicatat di log terlihat sebagai JSON valid.
  • Perhitungan HMAC lokal terhadap JSON dari log menghasilkan nilai berbeda.
  • Endpoint membalas 400 Bad Request atau 401 Unauthorized.
  • Request biasa dari Postman terkadang berhasil, tetapi request provider gagal.

JSON yang terlihat sama belum tentu memiliki byte yang sama. Perbedaan spasi, urutan properti, escape Unicode, dan newline di akhir payload akan menghasilkan HMAC berbeda.

{"id":"evt_123","type":"invoice.paid"}

{ "id": "evt_123", "type": "invoice.paid" }

Kedua payload tersebut menghasilkan object JavaScript yang sama setelah diparse, tetapi representasi byte dan signature-nya berbeda.

Langkah investigasi debug Nuxt Nitro

1. Pastikan kontrak signature provider

Sebelum mengubah kode, periksa dokumentasi provider. Setiap provider dapat menggunakan format berbeda, misalnya:

  • HMAC SHA-256 atas raw body.
  • Gabungan timestamp dan raw body.
  • Signature berformat hexadecimal atau Base64.
  • Header seperti x-webhook-signature atau nama khusus provider.
  • Prefix seperti sha256=.

Contoh dalam artikel ini menggunakan HMAC SHA-256 atas raw body dan header x-webhook-signature: sha256=<hex>. Sesuaikan format tersebut dengan kontrak provider yang sebenarnya.

2. Cari semua pihak yang membaca body

Periksa route, server middleware, modul observability, validasi schema, dan wrapper internal. Cari pemanggilan atau pola berikut:

readBody(event)
readRawBody(event)
event.node.req.on('data', ...)
event.node.req.on('end', ...)
event.request.json()
event.request.text()
JSON.stringify(parsedBody)

Body HTTP pada dasarnya merupakan stream yang harus diperlakukan sebagai sumber data sekali baca. Sebagian implementasi H3 dapat menyimpan hasil pembacaan internal ketika helper yang sama digunakan, tetapi kode aplikasi tidak sebaiknya bergantung pada detail tersebut. Pembacaan tingkat rendah, middleware pihak ketiga, atau penggunaan API request lain tetap dapat mengonsumsi stream.

Pola bermasalah yang umum adalah middleware logging berikut:

export default defineEventHandler(async (event) => {
  if (getRequestURL(event).pathname.startsWith('/api/webhooks/')) {
    const body = await readBody(event)
    console.info('Webhook body', body)
  }
})

Selain berpotensi mengekspos data sensitif, middleware tersebut melakukan parsing sebelum route memverifikasi signature. Masalah juga terjadi jika route menghitung HMAC dari object hasil parsing:

const body = await readBody(event)
const bytesForHmac = JSON.stringify(body) // Bukan raw body asli

3. Catat metadata, bukan payload atau secret

Tambahkan log sementara yang tidak membocorkan kredensial atau data pelanggan. Informasi yang umumnya cukup untuk debugging meliputi:

  • Path, method, dan content type.
  • Panjang raw body dalam byte.
  • Keberadaan header signature, tanpa mencatat nilainya.
  • ID request atau delivery dari provider.
  • Hasil verifikasi berupa boolean.
  • Status pemrosesan idempotensi.

Jangan mencatat secret, signature lengkap, authorization header, atau body webhook lengkap. Hash body juga perlu dipertimbangkan dengan hati-hati karena payload berentropi rendah masih mungkin ditebak.

Reproduksi request dengan curl

Reproduksi harus menjaga payload tetap identik. Perintah berikut menghitung HMAC dari string yang sama dengan data yang dikirim oleh curl:

export WEBHOOK_SECRET='development-secret'
payload='{"id":"evt_123","type":"invoice.paid"}'

signature=$(printf '%s' "$payload" \
  | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex \
  | awk '{print $2}')

curl -i -X POST 'http://localhost:3000/api/webhooks/provider' \
  -H 'content-type: application/json' \
  -H "x-webhook-signature: sha256=$signature" \
  --data-binary "$payload"

printf '%s' digunakan agar tidak menambahkan newline. Opsi --data-binary membantu mempertahankan data yang diberikan tanpa normalisasi yang tidak diperlukan.

Selanjutnya, kirim payload berbeda dengan signature lama. Endpoint yang benar harus menolaknya:

curl -i -X POST 'http://localhost:3000/api/webhooks/provider' \
  -H 'content-type: application/json' \
  -H "x-webhook-signature: sha256=$signature" \
  --data-binary '{"id":"evt_999","type":"invoice.paid"}'

Jika request pertama gagal, periksa kontrak algoritma dan secret. Jika request pertama berhasil tetapi request provider tetap gagal, bandingkan header, format signature, timestamp, content encoding, serta kemungkinan perubahan request oleh proxy.

Perbaikan: verifikasi readRawBody sebelum parsing

Pendekatan paling sederhana dan aman adalah melakukan seluruh proses pembacaan raw body, verifikasi, dan parsing di route yang sama. Contoh berikut ditujukan untuk runtime Node karena menggunakan node:crypto. Deployment edge memerlukan implementasi kriptografi yang sesuai dengan runtime, misalnya Web Crypto.

import { Buffer } from 'node:buffer'
import { createHmac, timingSafeEqual } from 'node:crypto'

function verifySignature(
  rawBody: Buffer,
  receivedHeader: string,
  secret: string
): boolean {
  const receivedHex = receivedHeader.startsWith('sha256=')
    ? receivedHeader.slice('sha256='.length)
    : receivedHeader

  if (!/^[a-f0-9]{64}$/i.test(receivedHex)) {
    return false
  }

  const expected = createHmac('sha256', secret)
    .update(rawBody)
    .digest()

  const received = Buffer.from(receivedHex, 'hex')

  return received.length === expected.length
    && timingSafeEqual(received, expected)
}

export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig(event)
  const secret = config.webhookSecret

  if (typeof secret !== 'string' || secret.length === 0) {
    throw createError({
      statusCode: 500,
      statusMessage: 'Webhook is not configured'
    })
  }

  const signature = getHeader(event, 'x-webhook-signature')
  if (!signature) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Missing webhook signature'
    })
  }

  const contentType = getHeader(event, 'content-type') ?? ''
  if (!contentType.toLowerCase().startsWith('application/json')) {
    throw createError({
      statusCode: 415,
      statusMessage: 'Unsupported content type'
    })
  }

  const rawBody = await readRawBody(event, false)
  if (!rawBody || rawBody.length === 0) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Empty webhook body'
    })
  }

  if (!verifySignature(rawBody, signature, secret)) {
    console.warn('Webhook signature rejected', {
      path: getRequestURL(event).pathname,
      contentType,
      bodyBytes: rawBody.length,
      signaturePresent: true
    })

    throw createError({
      statusCode: 401,
      statusMessage: 'Invalid webhook signature'
    })
  }

  let payload: unknown
  try {
    payload = JSON.parse(rawBody.toString('utf8'))
  } catch {
    throw createError({
      statusCode: 400,
      statusMessage: 'Invalid JSON body'
    })
  }

  // Validasi schema payload sebelum mengakses field atau memproses event.
  await processWebhook(payload)

  return { received: true }
})

Urutan operasinya sengaja dibuat ketat:

  1. Validasi konfigurasi dan header.
  2. Baca raw body satu kali sebagai Buffer.
  3. Hitung dan bandingkan HMAC.
  4. Parse JSON hanya setelah signature valid.
  5. Validasi schema dan jalankan proses bisnis.

timingSafeEqual mengurangi kebocoran informasi melalui perbedaan waktu perbandingan. Panjang kedua buffer harus diperiksa lebih dahulu karena fungsi tersebut mengharuskan ukuran yang sama.

Jangan menerapkan aturan content type yang lebih ketat daripada kontrak provider. Sebagian sistem menggunakan media type seperti application/cloudevents+json. Validasi harus mengikuti dokumentasi provider, bukan asumsi umum.

Menyimpan raw body di event.context

Jika verifikasi ditempatkan di middleware bersama, baca raw body hanya untuk path webhook yang relevan lalu simpan hasilnya di event.context. Dengan cara ini, route tidak perlu membaca stream kembali.

import type { Buffer } from 'node:buffer'

export default defineEventHandler(async (event) => {
  const path = getRequestURL(event).pathname

  if (getMethod(event) !== 'POST'
      || path !== '/api/webhooks/provider') {
    return
  }

  const rawBody = await readRawBody(event, false)
  if (!rawBody) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Empty webhook body'
    })
  }

  event.context.webhookRawBody = rawBody
})

Route kemudian mengambil buffer tersebut:

const rawBody = event.context.webhookRawBody as Buffer | undefined

if (!rawBody) {
  throw createError({
    statusCode: 400,
    statusMessage: 'Raw webhook body is unavailable'
  })
}

Tambahkan deklarasi tipe untuk properti context pada proyek TypeScript agar tidak mengandalkan type assertion di setiap route. Middleware ini juga harus dibatasi pada endpoint webhook; membaca seluruh request body secara global menambah konsumsi memori dan dapat mengganggu route lain.

Idempotensi dan keamanan pemrosesan webhook

Signature valid hanya membuktikan bahwa request sesuai dengan skema autentikasi provider. Signature tidak mencegah provider mengirim event yang sama beberapa kali. Retry dapat terjadi akibat timeout, gangguan jaringan, atau respons non-2xx.

Gunakan ID event unik dari provider sebagai kunci idempotensi. Simpan kombinasi provider dan event ID pada database dengan unique constraint. Alur yang aman adalah:

  1. Verifikasi signature dan timestamp jika disediakan.
  2. Validasi schema payload.
  3. Coba catat event ID dalam transaksi atau operasi insert atomik.
  4. Jika event sudah ada, kembalikan respons sukses tanpa mengulangi efek bisnis.
  5. Jika baru, proses langsung atau kirim ke queue.

Jangan hanya memakai Set dalam memori. Data tersebut hilang saat aplikasi restart dan tidak konsisten ketika Nitro dijalankan pada beberapa instance.

Jika provider menyertakan timestamp dalam signature, tetapkan toleransi usia request sesuai dokumentasinya untuk mengurangi risiko replay. Pastikan string yang ditandatangani sama persis; beberapa provider menghitung HMAC atas pola seperti timestamp.rawBody, bukan raw body saja.

Test integrasi untuk mencegah regresi

Test unit terhadap fungsi HMAC berguna, tetapi belum membuktikan bahwa middleware dan route menangani stream request dengan benar. Tambahkan test integrasi yang mengirim request HTTP ke server Nitro yang dijalankan oleh test harness proyek.

import { createHmac } from 'node:crypto'
import { describe, expect, it } from 'vitest'

const baseURL = process.env.TEST_BASE_URL
const secret = 'integration-test-secret'

function sign(payload: string): string {
  return 'sha256=' + createHmac('sha256', secret)
    .update(Buffer.from(payload, 'utf8'))
    .digest('hex')
}

describe('POST /api/webhooks/provider', () => {
  it('menerima payload dengan signature yang valid', async () => {
    const payload = '{"id":"evt_test_1","type":"invoice.paid"}'

    const response = await fetch(
      `${baseURL}/api/webhooks/provider`,
      {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          'x-webhook-signature': sign(payload)
        },
        body: payload
      }
    )

    expect(response.status).toBe(200)
  })

  it('menolak body yang berubah setelah ditandatangani', async () => {
    const signedPayload = '{"id":"evt_test_2","amount":100}'
    const tamperedPayload = '{"id":"evt_test_2","amount":900}'

    const response = await fetch(
      `${baseURL}/api/webhooks/provider`,
      {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          'x-webhook-signature': sign(signedPayload)
        },
        body: tamperedPayload
      }
    )

    expect(response.status).toBe(401)
  })
})

Konfigurasikan TEST_BASE_URL dan secret melalui setup test yang menjalankan build atau development server Nitro. Tambahkan kasus untuk header hilang, signature malformed, body kosong, JSON invalid, event duplikat, serta payload yang hanya berbeda whitespace atau newline.

Checklist akhir debugging

  • Pastikan algoritma, encoding, prefix, dan susunan data signature mengikuti dokumentasi provider.
  • Gunakan secret server-side dari runtime config, bukan konfigurasi publik.
  • Baca raw body sebelum parsing dan jangan menghitung HMAC dari JSON.stringify().
  • Audit middleware atau library yang membaca request body lebih dahulu.
  • Gunakan perbandingan signature yang aman dan validasi format header.
  • Jangan mencatat secret, signature lengkap, atau payload sensitif.
  • Implementasikan idempotensi berbasis penyimpanan persisten.
  • Uji endpoint melalui HTTP agar interaksi middleware dan stream ikut tervalidasi.

Inti perbaikannya bukan sekadar mengganti readBody dengan readRawBody, tetapi memastikan kepemilikan body request jelas. Raw body harus ditangkap satu kali, diverifikasi dalam bentuk byte asli, lalu baru diteruskan ke parsing dan proses bisnis.