webhook-security

v2026.09.25

Fix race conditions, add idempotency guards, and implement timing-safe token comparisons for payment webhooks. Covers Xendit, Stripe, Paddle, and generic webhook patterns. Prevents double-crediting, replay attacks, and timing attacks. Use when building payment webhooks, fixing race conditions, or securing callback endpoints.

GitHub
安装命令
npx skhub add afu-it/webhook-security
Markdown
SKILL.md

Webhook Security

Secure payment webhook endpoints against the three most common vulnerabilities: race conditions (double-credit), replay attacks, and timing attacks.

The Three Vulnerabilities

1. TOCTOU Race Condition (Double-Credit)

The bug: Check if webhook was already processed (SELECT), then process it (INSERT + UPDATE). Two concurrent webhooks both pass the SELECT before either commits.

BAD (vulnerable):

// Check-then-act -- RACE CONDITION
const existing = await db.prepare('SELECT id FROM transactions WHERE ref = ?').bind(ref).first();
if (!existing) {
  await addCredits(db, userId, credits, ref); // Both concurrent requests reach here
}

GOOD (atomic idempotency):

// INSERT OR IGNORE -- atomic, no race window
const result = await db.prepare(
  "INSERT OR IGNORE INTO transactions (user_id, amount, reason, ref, created_at) VALUES (?, ?, ?, ?, datetime('now'))"
).bind(userId, credits, 'purchase', ref).run();

if (result.meta.changes === 0) {
  // Duplicate -- already processed, return success without crediting
  return new Response(JSON.stringify({ ok: true }), { status: 200 });
}

// Only reaches here if INSERT succeeded (new transaction)
await db.prepare(
  "UPDATE user_credits SET credits = credits + ? WHERE user_id = ?"
).bind(credits, userId).run();

Why this works: The UNIQUE constraint on ref column makes the INSERT atomic. If two requests arrive simultaneously, only one INSERT succeeds (changes === 1). The other gets changes === 0 and returns early.

2. Timing Attack on Token Verification

The bug: Using === or !== for token comparison leaks information about the correct token through response timing.

BAD (vulnerable):

if (callbackToken !== expectedToken) {
  return new Response('Forbidden', { status: 403 });
}

GOOD (timing-safe):

function timingSafeEqual(a: string, b: string): boolean {
  const encoder = new TextEncoder();
  const bufA = encoder.encode(a);
  const bufB = encoder.encode(b);

  if (bufA.byteLength !== bufB.byteLength) return false;

  let mismatch = 0;
  for (let i = 0; i < bufA.byteLength; i++) {
    mismatch |= bufA[i] ^ bufB[i];
  }
  return mismatch === 0;
}

if (!expectedToken || !callbackToken || !timingSafeEqual(callbackToken, expectedToken)) {
  return new Response('Forbidden', { status: 403 });
}

Why this works: XOR comparison always iterates all bytes regardless of where the mismatch is. An attacker cannot determine correct bytes by measuring response time.

3. Replay Attack (Missing Idempotency)

The bug: Processing the same webhook multiple times because there's no deduplication.

Fix: Always store a unique reference (external_id, invoice_id) with a UNIQUE database constraint. The INSERT OR IGNORE pattern from fix #1 handles this automatically.

Complete Webhook Handler Template

import { auditLog, generateRequestId } from '@/lib/audit';

export async function POST(request: Request) {
  const reqId = generateRequestId();

  // 1. Verify token (timing-safe)
  const callbackToken = request.headers.get('x-callback-token');
  if (!verifyToken(callbackToken, env.WEBHOOK_TOKEN)) {
    auditLog('webhook.rejected', { reason: 'invalid_token' }, reqId);
    return new Response(JSON.stringify({ error: 'forbidden' }), { status: 403 });
  }

  // 2. Validate payload structure
  const body = await request.json().catch(() => null);
  if (!isValidPayload(body)) {
    auditLog('webhook.rejected', { reason: 'invalid_payload' }, reqId);
    return new Response(JSON.stringify({ error: 'invalid_payload' }), { status: 400 });
  }

  // 3. Skip non-paid events
  if (body.status !== 'PAID') {
    auditLog('webhook.skipped', { reason: 'not_paid' }, reqId);
    return new Response(JSON.stringify({ ok: true }), { status: 200 });
  }

  // 4. Validate credits match expected pack (defense-in-depth)
  const expectedCredits = PACKS[body.metadata.pack]?.credits;
  if (body.metadata.credits !== expectedCredits) {
    auditLog('webhook.rejected', { reason: 'credits_mismatch' }, reqId);
    return new Response(JSON.stringify({ error: 'invalid_payload' }), { status: 400 });
  }

  // 5. Atomic idempotent credit (INSERT OR IGNORE)
  try {
    const result = await db.prepare(
      "INSERT OR IGNORE INTO transactions (user_id, amount, reason, ref, created_at) VALUES (?, ?, ?, ?, datetime('now'))"
    ).bind(userId, credits, 'purchase', externalId).run();

    if (result.meta.changes === 0) {
      auditLog('webhook.duplicate', { externalId, userId }, reqId);
      return new Response(JSON.stringify({ ok: true }), { status: 200 });
    }

    await db.prepare(
      "UPDATE user_credits SET credits = credits + ?, updated_at = datetime('now') WHERE user_id = ?"
    ).bind(credits, userId).run();

    auditLog('webhook.credited', { externalId, userId, credits }, reqId);
  } catch (err) {
    auditLog('webhook.error', { externalId, error: String(err) }, reqId);
    return new Response(JSON.stringify({ error: 'internal_error' }), { status: 500 });
  }

  return new Response(JSON.stringify({ ok: true }), { status: 200 });
}

Database Schema Requirements

CREATE TABLE credit_transactions (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id TEXT NOT NULL,
  amount INTEGER NOT NULL,
  reason TEXT NOT NULL,
  xendit_ref TEXT,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  UNIQUE(xendit_ref)  -- THIS IS CRITICAL for idempotency
);

CREATE INDEX idx_transactions_user ON credit_transactions(user_id);
CREATE UNIQUE INDEX idx_transactions_ref ON credit_transactions(xendit_ref);

Provider-Specific Notes

Xendit

  • Token header: x-callback-token
  • Static shared secret (not HMAC)
  • Retries on 5xx responses

Stripe

  • Token header: stripe-signature
  • HMAC-SHA256 signature over request body
  • Use stripe.webhooks.constructEvent() for verification

Paddle

  • Token header: paddle-signature
  • HMAC-SHA256 with timestamp
  • Use Paddle SDK verification

Testing Webhook Security

test('replayed webhook does not double-credit', async () => {
  // First call should credit
  const res1 = await POST(makeWebhookRequest('invoice_123'));
  expect(res1.status).toBe(200);
  expect(creditsAdded).toBe(1);

  // Second call with same ID should NOT credit
  const res2 = await POST(makeWebhookRequest('invoice_123'));
  expect(res2.status).toBe(200);
  expect(creditsAdded).toBe(1); // Still 1, not 2
});

test('invalid token returns 403', async () => {
  const res = await POST(makeWebhookRequest('invoice_123', { token: 'wrong' }));
  expect(res.status).toBe(403);
});

PDPA 2024 Relevance

Race conditions in payment processing can lead to:

  • Financial loss (double-crediting)
  • Data integrity violations (inconsistent records)
  • Inability to reconcile transactions during audit

Under PDPA 2024, a Data Processor must implement "appropriate security measures." A payment system with known race conditions fails this standard.

发现
标签

此技能尚未发布标签。

版本
最新版本元数据

版本

v2026.09.25

发布时间

2026年9月25日

分类

未分类

许可证

未指定

源路径

skills/webhook-security

默认分支

main

最新提交

6aeeb8f

Tree SHA

14cb46d