Langsung ke konten
Tutorial

Keamanan webhook: tanda tangan HMAC, idempotensi, dan perlindungan pemutaran ulang

| 9 min read

Tiga pola kode menghentikan muatan palsu, pengiriman duplikat, dan permintaan webhook yang diputar ulang. Contoh Node.js dengan HMAC-SHA256 dan pemeriksaan stempel waktu.

Digital encryption padlock on a circuit board
Photo by Adi Goldstein on Unsplash

Pengendali webhook Anda menerima permintaan POST, mem-parsing JSON, dan menjalankan logika bisnis. Itu berhasil sampai seseorang mengirimkan muatan palsu ke titik akhir Anda. Atau sampai penyedia mencoba kembali pengiriman dan pengendali Anda mengenakan biaya a pelanggan dua kali. Atau hingga penyerang mencatat permintaan sah dan mengulanginya enam jam kemudian.

Tiga perlindungan memperbaikinya: verifikasi tanda tangan HMAC, kunci idempotensi, dan penolakan pemutaran ulang berbasis stempel waktu. Tutorial ini mencakup masing-masing kode Node.js yang berfungsi.

Cara kerja penandatanganan webhook

Setiap penyedia webhook utama (Stripe, GitHub, Shopify, Twilio) menandatangani payload keluar dengan HMAC-SHA256. Itu penyedia menggabungkan isi permintaan mentah dengan rahasia bersama, menghitung hash, dan mengirimkan hash tersebut dalam header. Server Anda menghitung ulang hash dengan rahasia yang sama dan membandingkannya. Jika hashnya cocok, payloadnya asli.

Rumus penandatanganannya terlihat seperti ini:

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

Stripe mengirimkan tanda tangannya Stripe-Signature. GitHub mengirimkannya X-Hub-Signature-256. Algoritmenya sama; hanya nama header dan formatnya yang berbeda.

Verifikasi tanda tangan webhook Stripe di Node.js

garis Stripe-Signature header berisi stempel waktu (t=) dan satu atau lebih versi tanda tangan (v1=). Berikut cara memverifikasinya tanpa Stripe SDK:

const crypto = require('crypto');

function verifyStripeSignature(payload, header, secret) {
  const parts = header.split(',');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const signature = parts.find(p => p.startsWith('v1=')).slice(3);

  const signedPayload = timestamp + '.' + payload;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  if (expected !== signature) {
    throw new Error('Invalid signature');
  }

  // Replay protection: reject if older than 5 minutes
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (age > 300) {
    throw new Error('Timestamp too old');
  }

  return JSON.parse(payload);
}

Detail utama: Stripe menggabungkan stempel waktu dan isi mentah dengan pemisah titik. Itu v1 tanda tangan menggunakan HMAC-SHA256. Pemeriksaan stempel waktu di bagian akhir memberikan perlindungan pemutaran ulang (dibahas secara rinci di bawah).

Verifikasi tanda tangan webhook GitHub di Node.js

Format GitHub lebih sederhana. Itu X-Hub-Signature-256 tajuk berisi sha256= diikuti dengan hex HMAC dari badan mentah:

const crypto = require('crypto');

function verifyGitHubSignature(payload, signatureHeader, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  const trusted = Buffer.from(expected, 'utf8');
  const untrusted = Buffer.from(signatureHeader, 'utf8');

  if (trusted.length !== untrusted.length) {
    throw new Error('Invalid signature');
  }

  if (!crypto.timingSafeEqual(trusted, untrusted)) {
    throw new Error('Invalid signature');
  }

  return JSON.parse(payload);
}

Melihat crypto.timingSafeEqual. Sebuah standar === perbandingan membocorkan informasi waktu. Seorang penyerang dapat mengukur waktu respons untuk menebak tanda tangan yang benar byte demi byte. Perbandingan waktu konstan menghilangkan vektor tersebut.

Hitung tanda tangan HMAC dengan API hash botoi

Jika Anda perlu menghitung atau memverifikasi tanda tangan HMAC-SHA256 dari skrip, pipeline CI, atau fungsi tanpa server tanpa mengimpor crypto, gunakan /v1/hash/hmac titik akhir:

curl -X POST https://api.botoi.com/v1/hash/hmac \\
  -H "Content-Type: application/json" \\
  -d '{
    "text": "1712345678.{\\"event\\":\\"payment_intent.succeeded\\",\\"amount\\":4999}",
    "key": "whsec_your_stripe_secret",
    "algorithm": "sha256"
  }'

Tanggapan:

{
  "success": true,
  "data": {
    "hmac": "a3f2b8c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0",
    "algorithm": "sha256"
  }
}

Ini mengembalikan HMAC yang dikodekan hex. Bandingkan dengan header tanda tangan dari penyedia webhook. Titik akhir mendukung keduanya sha256 Dan sha512 algoritma.

Bangun middleware Express untuk verifikasi HMAC

Bungkus pemeriksaan tanda tangan ke dalam middleware yang dapat digunakan kembali sehingga setiap rute webhook mendapat perlindungan tanpa menggandakan kode:

const crypto = require('crypto');

function webhookAuth(secret) {
  return (req, res, next) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];

    if (!signature || !timestamp) {
      return res.status(401).json({ error: 'Missing signature headers' });
    }

    // Step 1: Replay protection
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
    if (age > 300) {
      return res.status(401).json({ error: 'Request too old' });
    }

    // Step 2: HMAC verification
    const payload = JSON.stringify(req.body);
    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + '.' + payload)
      .digest('hex');

    const trusted = Buffer.from(expected, 'utf8');
    const untrusted = Buffer.from(signature, 'utf8');

    if (trusted.length !== untrusted.length ||
        !crypto.timingSafeEqual(trusted, untrusted)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    next();
  };
}

// Mount on your webhook route
app.post('/webhooks/stripe', webhookAuth(process.env.STRIPE_WEBHOOK_SECRET), handler);

Middleware ini menolak permintaan dengan header yang hilang, stempel waktu yang sudah usang, atau tanda tangan yang tidak valid sebelum Anda pawang berjalan. Jendela 300 detik cocok dengan toleransi default Stripe.

Idempotensi: lewati pengiriman duplikat

Penyedia webhook mencoba kembali pengiriman yang gagal. Jika server Anda mengembalikan 500 setelah memproses acara tetapi sebelumnya mengirimkan respons 200, penyedia mengirimkan peristiwa yang sama lagi. Tanpa idempotensi, pawang Anda menjalankan efek samping dua kali: tagihan ganda, email duplikat, pembaruan inventaris berulang.

Cara mengatasinya: simpan setiap ID peristiwa yang diproses dan lewati peristiwa yang pernah Anda lihat sebelumnya.

Idempotensi dalam memori (perkembangan)

const processedEvents = new Map(); // Use Redis in production

async function handleWebhook(req, res) {
  const eventId = req.body.id;

  // Check if already processed
  if (processedEvents.has(eventId)) {
    return res.status(200).json({ status: 'duplicate', eventId });
  }

  // Mark as processing before side effects
  processedEvents.set(eventId, Date.now());

  try {
    await processEvent(req.body);
    return res.status(200).json({ status: 'processed', eventId });
  } catch (err) {
    // Remove on failure so retries work
    processedEvents.delete(eventId);
    return res.status(500).json({ error: 'Processing failed' });
  }
}

Itu Map bekerja untuk pembangunan lokal. Dalam produksi, gunakan toko bersama untuk semua server instance melihat kumpulan peristiwa yang diproses yang sama.

Idempotensi yang didukung Redis (produksi)

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

async function handleWebhook(req, res) {
  const eventId = req.body.id;
  const lockKey = \`webhook:processed:\${eventId}\

SET NX bersifat atomik: hanya satu proses yang memenangkan kunci. TTL 72 jam (259200 detik) memangkas entri lama secara otomatis. Jika pemrosesan gagal, pengendali akan menghapus kunci sehingga percobaan ulang berikutnya dapat berhasil.

Perlindungan pemutaran ulang: tolak permintaan basi

Verifikasi HMAC mengonfirmasi bahwa muatan berasal dari penyedia. Namun permintaan ditandatangani yang valid ditangkap oleh a penyerang jaringan tetap valid selamanya kecuali Anda menambahkan pemeriksaan waktu. Perlindungan pemutaran ulang menolak muatan dengan stempel waktu yang lebih tua dari ambang batas.

function rejectStaleRequests(req, res, next) {
  const timestamp = parseInt(req.headers['x-webhook-timestamp'], 10);

  if (isNaN(timestamp)) {
    return res.status(401).json({ error: 'Missing timestamp' });
  }

  const now = Math.floor(Date.now() / 1000);
  const age = now - timestamp;

  // Reject anything older than 5 minutes or from the future
  if (age > 300 || age < -30) {
    return res.status(401).json({ error: 'Timestamp out of range' });
  }

  next();
}

Toleransi 30 detik di masa depan menangani penyimpangan jam kecil antara server Anda dan penyedia. Apa saja lebih dari 5 menit atau lebih dari 30 detik di masa depan akan ditolak.

Gabungkan ketiganya menjadi satu middleware

Berikut middleware lengkap yang menjalankan ketiga pemeriksaan secara berurutan: stempel waktu, HMAC, lalu idempotensi.

const crypto = require('crypto');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

function secureWebhook(secret) {
  return async (req, res, next) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];
    const eventId = req.body?.id;

    // 1. Reject missing headers
    if (!signature || !timestamp || !eventId) {
      return res.status(401).json({ error: 'Missing required headers or event ID' });
    }

    // 2. Replay protection: reject stale requests
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
    if (age > 300 || age < -30) {
      return res.status(401).json({ error: 'Timestamp out of range' });
    }

    // 3. HMAC verification
    const payload = JSON.stringify(req.body);
    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + '.' + payload)
      .digest('hex');

    const trusted = Buffer.from(expected, 'utf8');
    const untrusted = Buffer.from(signature, 'utf8');

    if (trusted.length !== untrusted.length ||
        !crypto.timingSafeEqual(trusted, untrusted)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // 4. Idempotency: skip duplicates
    const lockKey = \`webhook:processed:\${eventId}\

Urutan itu penting. Stempel waktu adalah pemeriksaan termurah (tanpa I/O), sehingga dijalankan terlebih dahulu. Verifikasi HMAC adalah Terikat CPU tetapi masih cepat. Idempotensi memerlukan panggilan Redis, sehingga idempotensi berjalan terakhir. Urutan ini ditolak permintaan terbanyak dengan biaya terendah.

Uji pengendali webhook Anda dengan kotak masuk webhook botoi

Gunakan tombol /v1/webhook/inbox untuk membuat URL sekali pakai, hitung tanda tangan HMAC dengan /v1/hash/hmac, dan mengirimkan payload pengujian yang ditandatangani. Tidak ada terowongan, tidak ada server lokal.

# 1. Create a webhook inbox
INBOX=\$(curl -s -X POST https://api.botoi.com/v1/webhook/inbox/create)
INBOX_ID=\$(echo \$INBOX | jq -r '.data.inbox_id')
INBOX_URL=\$(echo \$INBOX | jq -r '.data.url')
echo "Inbox URL: \$INBOX_URL"

# 2. Compute an HMAC signature for your test payload
PAYLOAD='{"id":"evt_001","event":"order.created","amount":2999}'
TIMESTAMP=\$(date +%s)
SIGNED_PAYLOAD="\$TIMESTAMP.\$PAYLOAD"

HMAC=\$(curl -s -X POST https://api.botoi.com/v1/hash/hmac \\
  -H "Content-Type: application/json" \\
  -d "{
    \\"text\\": \\"\$SIGNED_PAYLOAD\\",
    \\"key\\": \\"test_secret_key\\",
    \\"algorithm\\": \\"sha256\\"
  }" | jq -r '.data.hmac')
echo "HMAC: \$HMAC"

# 3. Send the signed payload to your inbox
curl -s -X POST "\$INBOX_URL" \\
  -H "Content-Type: application/json" \\
  -H "X-Webhook-Signature: \$HMAC" \\
  -H "X-Webhook-Timestamp: \$TIMESTAMP" \\
  -d "\$PAYLOAD"

# 4. Retrieve and inspect the payload
curl -s -X POST "https://api.botoi.com/v1/webhook/inbox/\$INBOX_ID/list" | jq '.data.payloads'

Titik akhir daftar mengembalikan semua payload yang diterima:

[
  {
    "id": "p_abc123",
    "received_at": "2026-04-05T14:30:00Z",
    "body": {
      "id": "evt_001",
      "event": "order.created",
      "amount": 2999
    }
  }
]

Alur kerja ini memungkinkan Anda menguji alur penandatanganan penuh dari baris perintah. Tukar URL kotak masuk dengan URL Anda titik akhir produksi untuk menjalankan pengujian integrasi di CI.

Cara Stripe, GitHub, dan Shopify menandatangani webhook

Penyedia Tajuk Algoritma Format muatan yang ditandatangani Termasuk stempel waktu
Garis Stripe-Signature HMAC-SHA256 t=UNIX.v1=HEX Ya
GitHub X-Hub-Signature-256 HMAC-SHA256 sha256=HEX TIDAK
Shopify X-Shopify-Hmac-Sha256 HMAC-SHA256 Dikodekan Base64 TIDAK
Twilio X-Twilio-Signature HMAC-SHA1 URL + parameter yang diurutkan TIDAK

Stripe adalah satu-satunya yang menggabungkan stempel waktu dengan tanda tangan, memberi Anda perlindungan pemutaran ulang secara langsung. Untuk GitHub dan Shopify, tambahkan header stempel waktu Anda sendiri atau periksa waktu pembuatan acara dari badan payload.

Daftar periksa sebelum pergi ke produksi

  • Simpan rahasia webhook dalam variabel lingkungan, bukan dalam kode sumber.
  • Parsing isi permintaan mentah, bukan versi yang diurai JSON. HMAC menandatangani byte yang tepat pada kabel.
  • Menggunakan crypto.timingSafeEqual untuk perbandingan tanda tangan. Jangan pernah gunakan ===.
  • Kembalikan 200 sebelum menjalankan pemrosesan lambat. Antrian pekerjaan dan akui pengirimannya.
  • Catat permintaan yang ditolak (tanda tangan tidak valid, stempel waktu basi, duplikat) untuk debugging dan peringatan.
  • Tetapkan TTL di penyimpanan idempotensi Anda. 72 jam mencakup sebagian besar jendela coba ulang penyedia.
  • Uji dengan kotak masuk sekali pakai dan hitung tanda tangan HMAC sebelum menghubungkan penyedia langsung.

FAQ

Mengapa menggunakan HMAC-SHA256 alih-alih rahasia bersama dalam parameter kueri?
Parameter kueri berjalan dalam teks biasa melalui proksi dan log akses. HMAC-SHA256 menandatangani seluruh isi permintaan dengan rahasia tersebut, sehingga penyerang yang menyadap URL masih tidak dapat memalsukan tanda tangan yang valid.
Berapa lama saya harus menyimpan ID peristiwa yang diproses untuk idempotensi?
Simpan setidaknya selama 24 hingga 72 jam. Sebagian besar penyedia webhook mencoba lagi dalam jendela tersebut. Setelah 72 jam, Anda dapat dengan aman memangkas ID lama dari toko Anda.
Toleransi stempel waktu apa yang harus saya gunakan untuk perlindungan pemutaran ulang?
Lima menit (300 detik) adalah standarnya. Stripe menggunakan 300 detik. Jendela yang lebih pendek berisiko menolak pengiriman sah yang tertunda karena kemacetan jaringan.
Bisakah saya menggunakan botoi hash/hmac endpoint untuk memverifikasi webhook yang masuk?
Ya. POST isi payload dan rahasia Anda ke /v1/hash/hmac dengan algoritma sha256. Bandingkan HMAC yang dikembalikan dengan header tanda tangan dari penyedia webhook.
Apakah saya memerlukan ketiga perlindungan tersebut atau bolehkah saya memilih salah satu?
Verifikasi HMAC adalah minimum. Tambahkan idempotensi jika pengendali webhook Anda memicu efek samping seperti tagihan atau email. Tambahkan perlindungan pemutaran ulang jika sistem Anda menangani transaksi keuangan atau peristiwa sensitif keamanan.

Mulai membangun dengan botoi

150+ endpoint API untuk pencarian, pemrosesan teks, pembuatan gambar, dan utilitas developer. Paket gratis, tanpa kartu kredit.