# Webhook Security

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

- Skill: `afu-it/webhook-security` (Agent Skill)
- Install (CLI): `npx skillmds@latest add afu-it/webhook-security`
- Raw SKILL.md: https://api.skillmd.com/api/skills/afu-it/webhook-security/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: afu-it (https://skillmd.com/u/afu-it)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/afu-it/webhook-security

---


# 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):
```typescript
// 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):
```typescript
// 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):
```typescript
if (callbackToken !== expectedToken) {
  return new Response('Forbidden', { status: 403 });
}
```

GOOD (timing-safe):
```typescript
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

```typescript
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

```sql
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

```typescript
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.

