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
Install command
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.

Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.25

Published

Sep 25, 2026

Category

Uncategorized

License

Not specified

Source path

skills/webhook-security

Default branch

main

Latest commit

6aeeb8f

Tree SHA

14cb46d