VibeWeek
Home/Grow/Webhook Signature Verification & Replay Protection: HMAC, Timestamps, and How Not to Get Pwned

Webhook Signature Verification & Replay Protection: HMAC, Timestamps, and How Not to Get Pwned

⬅️ Day 6: Grow Overview

If you're running a SaaS in 2026 and either sending OR receiving webhooks, you have a security boundary that's invisible until it's exploited. Most founders ship the happy-path webhook — Stripe sends event, your endpoint processes it — and never implement signature verification. Six months in, an attacker discovers your endpoint, posts a fake "subscription.activated" event, and unlocks paid features for free. Or your team sends webhooks to customers without signing them; one customer gets phished by a forged "webhook" and loses data.

A working webhook-security strategy answers: how do we sign outbound webhooks, how do we verify inbound webhooks, how do we prevent replay attacks, what happens when keys rotate, and how do we test verification without breaking production. Done well, webhook security is invisible. Done badly, you have a vulnerability that scales with your customer base.

This guide is the implementation playbook for webhook signature verification + replay protection — the cryptographic patterns, code examples, key rotation, and the discipline that prevents the most common webhook security mistakes. Companion to Outbound Webhooks (sending) and Inbound Webhooks (receiving).

Why Signature Verification Matters

Get the threat model straight first.

Help me understand the threats.

The threats:

**1. Forged webhooks (fake "events")**

Without verification:
- Attacker discovers your endpoint URL
- Posts fake "payment.succeeded" event
- Your code grants access; attacker pays nothing

**2. Modified payloads (man-in-the-middle)**

If transport encryption fails:
- Attacker intercepts; modifies amount from $50 to $5000
- You credit wrong amount
- Or vice versa: $5000 → $50

**3. Replay attacks (legitimate event resent)**

- Attacker captures a real "refund.created" webhook
- Replays it 100 times
- You issue 100 refunds

**4. Webhook source impersonation**

Without sender verification:
- Customer trusts your webhooks
- Attacker mimics your URL
- Sends fake "API key changed" events to phish

**The common defense**: HMAC signatures + timestamps.

**HMAC** = Hash-based Message Authentication Code. Both sender and receiver share a secret; sender hashes (payload + secret) and includes hash in header; receiver hashes (received-payload + secret) and compares.

**Timestamp** = unique marker per event; receiver rejects events older than N minutes (prevents replay).

**The 90% answer**:

For both inbound and outbound webhooks:
- Use HMAC SHA-256 signatures
- Include timestamp in signed payload
- Reject events older than 5 minutes
- Document signature scheme for customers

For my system:
- Webhooks sent? received?
- Current verification?
- Recent security incidents?

Output:
1. The threat model
2. The current state
3. The priority

The biggest unforced error: trusting webhooks because they "come from Stripe." Without signature verification, you have no proof. The IP could be anyone. The fix: signature verification is mandatory; not optional. Cheap to implement; prevents catastrophic exploits.

Verifying Inbound Webhooks (Stripe / Others)

When you RECEIVE webhooks, verify them.

Help me verify inbound webhooks.

The general pattern:

**Step 1: Webhook arrives with signature header**

POST /webhooks/stripe HTTP/1.1 Content-Type: application/json Stripe-Signature: t=1614000000,v1=abc123...

{"type": "payment_intent.succeeded", ...}


The signature header includes:
- `t=` — timestamp
- `v1=` — HMAC SHA-256 of `timestamp.payload`

**Step 2: Verify**

```typescript
import crypto from 'crypto';

function verifyStripeWebhook(
  payload: string,        // raw body
  header: string,         // Stripe-Signature header
  secret: string,         // Webhook secret from Stripe
  tolerance: number = 300 // 5 minutes
): { valid: boolean; reason?: string } {
  // Parse header
  const parts = header.split(',');
  const tsPart = parts.find(p => p.startsWith('t='))?.slice(2);
  const sigPart = parts.find(p => p.startsWith('v1='))?.slice(3);

  if (!tsPart || !sigPart) return { valid: false, reason: 'Missing timestamp or signature' };

  const timestamp = parseInt(tsPart);
  const now = Math.floor(Date.now() / 1000);

  // Reject old events (replay protection)
  if (Math.abs(now - timestamp) > tolerance) {
    return { valid: false, reason: 'Timestamp outside tolerance' };
  }

  // Compute expected signature
  const signedPayload = `${timestamp}.${payload}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Constant-time comparison (prevents timing attacks)
  const valid = crypto.timingSafeEqual(
    Buffer.from(sigPart),
    Buffer.from(expected)
  );

  return { valid, reason: valid ? undefined : 'Signature mismatch' };
}

// Usage in API route
export async function POST(req: Request) {
  const payload = await req.text();  // RAW body; before parsing
  const sig = req.headers.get('Stripe-Signature');

  const { valid, reason } = verifyStripeWebhook(
    payload,
    sig,
    process.env.STRIPE_WEBHOOK_SECRET!
  );

  if (!valid) {
    return new Response(`Invalid: ${reason}`, { status: 400 });
  }

  const event = JSON.parse(payload);
  await processEvent(event);

  return new Response('OK', { status: 200 });
}

Critical implementation rules:

1. Use raw body BEFORE parsing

JSON parsing changes whitespace; signature won''t match.

In Next.js / Express:

  • Read raw body as text first
  • Then JSON.parse for processing
  • Don''t use middleware that auto-parses JSON before signature check

2. Use constant-time comparison

a === b is timing-vulnerable. Use crypto.timingSafeEqual.

3. Timestamp tolerance window

5 minutes (300 seconds) is standard:

  • Allows clock skew + transit time
  • Tight enough to prevent meaningful replay

4. Library helpers

Stripe SDK:

import Stripe from 'stripe';
const event = stripe.webhooks.constructEvent(payload, sig, secret);

This handles all the verification automatically. Use the SDK when available.

5. Failed-verification handling

  • Return 400 (bad request)
  • Don''t leak which check failed (information disclosure)
  • Log internally for debugging
  • Alert on patterns (many failures = attack OR bug)

Per-provider patterns:

Provider Header Algorithm
Stripe Stripe-Signature HMAC SHA-256
GitHub X-Hub-Signature-256 HMAC SHA-256
Slack X-Slack-Signature + X-Slack-Request-Timestamp HMAC SHA-256
PayPal Paypal-Transmission-Sig RSA
Twilio X-Twilio-Signature HMAC SHA-1
Shopify X-Shopify-Hmac-SHA256 HMAC SHA-256

Each provider docs have specific verification code; follow them.

For my inbound:

  • Webhook sources I receive
  • Library / manual verification
  • Tolerance window

Output:

  1. The verification implementation per source
  2. The library usage
  3. The error handling

The biggest inbound-verification mistake: **using parsed JSON for signature.** Express'' default JSON middleware parses body; signature check fails (whitespace differs). The fix: capture raw body BEFORE parsing.

## Signing Outbound Webhooks (Sending to Customers)

When you SEND webhooks, sign them.

Help me sign outbound webhooks.

The pattern:

Step 1: Generate signature

import crypto from 'crypto';

function signWebhook(payload: string, secret: string): { timestamp: number; signature: string } {
  const timestamp = Math.floor(Date.now() / 1000);
  const signedPayload = `${timestamp}.${payload}`;

  const signature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  return { timestamp, signature };
}

Step 2: Include in header

async function sendWebhook(url: string, event: any, secret: string) {
  const payload = JSON.stringify(event);
  const { timestamp, signature } = signWebhook(payload, secret);

  await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Acme-Signature': `t=${timestamp},v1=${signature}`,
      'X-Acme-Event-Id': event.id,
      'X-Acme-Event-Type': event.type,
    },
    body: payload,
  });
}

Step 3: Document for customers

Customers need:

  • Algorithm (HMAC SHA-256)
  • Header format (t=...,v1=...)
  • What''s signed (timestamp.payload)
  • Tolerance (5 minutes)
  • Code samples in popular languages

Per api-documentation-tools: include in API docs.

Step 4: Per-customer signing secrets

DON''T use one global secret for all customers. Each customer / endpoint has unique secret.

CREATE TABLE webhook_endpoints (
  id UUID PRIMARY KEY,
  customer_id UUID NOT NULL,
  url TEXT NOT NULL,
  signing_secret TEXT NOT NULL,  -- unique per endpoint
  created_at TIMESTAMPTZ DEFAULT now()
);

Why:

  • One leaked secret doesn''t affect others
  • Easy to rotate per-customer
  • Audit trail per endpoint

Step 5: Provide secret to customer securely

When customer creates webhook endpoint:

  • Generate secret
  • Show ONCE (encrypt at rest after)
  • "Store this securely; we won''t show it again"

If customer loses: they regenerate (we don''t have it).

Step 6: Webhook ID for idempotency

Per idempotency-patterns-chat:

Include unique event ID in header. Customers should dedupe.

The full outbound headers:

POST /customer-endpoint HTTP/1.1
Content-Type: application/json
X-Acme-Signature: t=1614000000,v1=abc123...
X-Acme-Event-Id: evt_xxxxx
X-Acme-Event-Type: payment.succeeded
X-Acme-Delivery-Attempt: 1
User-Agent: Acme-Webhooks/1.0

For my outbound:

  • Per-customer secret storage
  • Header format
  • Documentation

Output:

  1. The signing implementation
  2. The secret-management
  3. The customer-facing docs

The biggest outbound-signing mistake: **one global secret for all customers.** Customer A''s secret leaks; attacker can forge webhooks to Customer B. The fix: per-endpoint secrets; rotated independently.

## Replay Protection

Signatures alone don''t prevent replay. Add timestamp + nonce.

Help me prevent replay attacks.

The threat:

Attacker captures legitimate webhook with valid signature. Replays N times. Your handler processes each as new.

Defense 1: Timestamp + tolerance window

Already covered above:

  • Sign payload includes timestamp
  • Receiver rejects events older than 5 minutes
  • Replay only works within window

This handles: replay after delay > 5 min. Doesn''t handle: replay within 5 min.

Defense 2: Idempotency on event ID

Per idempotency-patterns-chat:

Receiver tracks processed event IDs:

async function handleWebhook(event: any) {
  // Check if already processed
  const existing = await redis.get(`webhook:${event.id}`);
  if (existing) {
    return { status: 'already_processed' };
  }

  // Process
  await processEvent(event);

  // Mark processed (TTL = 24h or longer)
  await redis.set(`webhook:${event.id}`, '1', 'EX', 86400);
}

Replay attempts within 24h: deduplicated.

Defense 3: Nonce in header

Some systems include a unique nonce per request:

X-Acme-Nonce: abc-123-def

Receiver tracks nonces; rejects duplicates.

Less common than event-ID dedup; nonce is essentially the event ID.

Defense 4: Per-customer rate limit

If attacker replays many times: rate-limit per source. Per rate-limiting-abuse-chat.

The combined defense:

async function handleWebhook(payload: string, sig: string, secret: string) {
  // 1. Verify signature
  const { valid } = verifySignature(payload, sig, secret);
  if (!valid) return 400;

  const event = JSON.parse(payload);

  // 2. Verify timestamp (already in signature verification)

  // 3. Idempotency check
  const seen = await redis.get(`webhook:${event.id}`);
  if (seen) return { status: 200, message: 'already processed' };

  // 4. Process
  await processEvent(event);

  // 5. Mark processed
  await redis.set(`webhook:${event.id}`, '1', 'EX', 86400 * 7);

  return 200;
}

For my system:

  • Replay protection layers
  • Idempotency window
  • Nonce strategy

Output:

  1. The replay-defense plan
  2. The idempotency window
  3. The implementation

The biggest replay mistake: **signature-only defense.** Signature verifies authenticity but doesn''t prevent replay of legitimate-but-already-processed events. The fix: signature + timestamp + idempotency = defense in depth.

## Key Rotation

Secrets get compromised. Plan for rotation.

Help me handle key rotation.

The scenarios:

Scenario 1: Customer secret compromised

Customer reports leak; need new secret immediately.

// Customer requests rotation
async function rotateEndpointSecret(endpointId: string) {
  const newSecret = crypto.randomBytes(32).toString('hex');
  await db.webhookEndpoints.update(endpointId, {
    signing_secret: newSecret,
    rotated_at: new Date(),
  });
  return newSecret;  // show to customer once
}

Issue: in-flight webhooks signed with OLD secret will fail verification.

Solution: brief overlap window where BOTH secrets accepted.

Defense: dual-secret rotation

ALTER TABLE webhook_endpoints
ADD COLUMN previous_secret TEXT,
ADD COLUMN previous_secret_expires_at TIMESTAMPTZ;

On rotation:

  • New secret becomes primary
  • Old secret moves to previous_secret
  • previous_secret_expires_at = now + 24h
  • Receiver tries new secret first; falls back to previous if not expired
  • After 24h: previous_secret cleared

Verification with dual secrets:

const valid = verifySignature(payload, sig, endpoint.signing_secret) ||
  (endpoint.previous_secret &&
   endpoint.previous_secret_expires_at > new Date() &&
   verifySignature(payload, sig, endpoint.previous_secret));

24h overlap window allows graceful transition.

Scenario 2: Your system''s shared secret compromised

If you use a single secret (less common):

  • Same dual-secret approach but global

Scenario 3: Provider rotates their secret

Stripe rotates webhook signing secret:

  • Stripe gives new secret in dashboard
  • Update env var
  • Stripe sends with new secret going forward

If you missed events during rotation: replay from Stripe events API.

The "scheduled rotation" cadence:

For high-security:

  • Auto-rotate webhook secrets every 90 days
  • Customer notified
  • Dual-secret window 24h

For most: rotate on demand only.

The "secret in env vs DB" decision:

  • Receiving from one provider (Stripe): env var
  • Sending to many customers: DB (per-customer)

Don''t store production secrets in code. Per secret-management-providers.

For my system:

  • Rotation policy
  • Dual-secret implementation
  • Customer-rotation flow

Output:

  1. The rotation strategy
  2. The dual-secret implementation
  3. The customer flow

The biggest rotation mistake: **immediate cutover, breaking in-flight webhooks.** Customer rotates; events in flight signed with old secret; verification fails; events lost. The fix: dual-secret window for graceful transition.

## Testing Verification

Don''t deploy verification untested.

Help me test webhook verification.

The tests:

1. Valid signature passes

test('valid signature accepted', async () => {
  const payload = JSON.stringify({ type: 'test' });
  const { timestamp, signature } = signWebhook(payload, secret);

  const valid = verifySignature(
    payload,
    `t=${timestamp},v1=${signature}`,
    secret
  );

  expect(valid.valid).toBe(true);
});

2. Invalid signature rejected

test('tampered payload rejected', async () => {
  const payload = JSON.stringify({ type: 'test', amount: 100 });
  const { timestamp, signature } = signWebhook(payload, secret);

  const tampered = payload.replace('100', '10000');

  const valid = verifySignature(
    tampered,
    `t=${timestamp},v1=${signature}`,
    secret
  );

  expect(valid.valid).toBe(false);
});

3. Wrong secret rejected

test('wrong secret rejected', async () => {
  const payload = JSON.stringify({ type: 'test' });
  const { timestamp, signature } = signWebhook(payload, 'secret-a');

  const valid = verifySignature(
    payload,
    `t=${timestamp},v1=${signature}`,
    'secret-b'
  );

  expect(valid.valid).toBe(false);
});

4. Old timestamp rejected

test('old timestamp rejected', async () => {
  const payload = JSON.stringify({ type: 'test' });
  const oldTimestamp = Math.floor(Date.now() / 1000) - 600;  // 10 min ago

  const signature = crypto
    .createHmac('sha256', secret)
    .update(`${oldTimestamp}.${payload}`)
    .digest('hex');

  const valid = verifySignature(
    payload,
    `t=${oldTimestamp},v1=${signature}`,
    secret
  );

  expect(valid.valid).toBe(false);
  expect(valid.reason).toContain('timestamp');
});

5. Missing header rejected

test('missing signature header rejected', async () => {
  const valid = verifySignature(payload, '', secret);
  expect(valid.valid).toBe(false);
});

6. Replay protection (idempotency)

test('replay rejected via idempotency', async () => {
  const event = { id: 'evt_test' };
  await handleWebhook(event);  // first
  const result = await handleWebhook(event);  // replay
  expect(result.status).toBe('already_processed');
});

7. Dual-secret rotation works

test('dual-secret accepts old secret in window', async () => {
  // Sign with old secret
  // Rotate; new secret active; old in window
  // Verify still passes
});

For my testing:

  • Test coverage
  • CI integration

Output:

  1. The test plan
  2. The CI integration
  3. The "before deploy" verification

The biggest testing mistake: **shipping verification untested in production.** First webhook arrives; verification fails for unknown reason; you panic-disable verification "for now"; never re-enable. The fix: comprehensive test coverage; production-equivalent test cases; verify works locally before deploy.

## Avoid Common Pitfalls

Recognizable failure patterns.

The webhook-security mistake checklist.

Mistake 1: No signature verification

  • Fake events accepted
  • Fix: verify every inbound webhook

Mistake 2: Parsed body in signature

  • JSON whitespace breaks signature
  • Fix: raw body before parse

Mistake 3: String equality (timing attack)

  • a === b leaks signature info
  • Fix: timingSafeEqual

Mistake 4: No timestamp check (replay vulnerable)

  • Old events accepted
  • Fix: 5-minute tolerance window

Mistake 5: No idempotency on event ID

  • Replay within window works
  • Fix: dedupe by event ID

Mistake 6: One global secret for all customers

  • Cross-customer compromise risk
  • Fix: per-endpoint secrets

Mistake 7: Immediate rotation breaks in-flight

  • Lost webhooks during rotation
  • Fix: dual-secret window

Mistake 8: Secret in code repo

  • Leaks via git history
  • Fix: env vars / secret manager

Mistake 9: Verification untested

  • Bug ships; production fails
  • Fix: explicit test cases

Mistake 10: No documentation for customers

  • Customers can''t verify our webhooks
  • Fix: code samples in API docs

The quality checklist:

  • Inbound: signature verification on every webhook
  • Raw body used (before parse)
  • Constant-time comparison
  • Timestamp tolerance window (5 min)
  • Outbound: per-endpoint signing secrets
  • Replay protection (event-ID dedup)
  • Dual-secret rotation
  • Secret rotation flow for customers
  • Tests cover all edge cases
  • Customer-facing verification docs

For my system:

  • Audit
  • Top 3 fixes

Output:

  1. Audit
  2. Top 3 fixes
  3. The "v2 webhook security" plan

The single most-common mistake: **assuming "it''s probably fine" without verification.** Production webhooks accepted from anyone; nobody''s exploited it yet; comfort builds. The fix: signature verification is non-negotiable. Add now; thank yourself later.

---

## What "Done" Looks Like

A working webhook-security system in 2026 has:

- Inbound: signature verification on every webhook (using SDK or manual)
- Raw body used (before JSON parse)
- Constant-time comparison
- 5-minute timestamp tolerance
- Outbound: per-endpoint signing secrets stored encrypted
- Event-ID idempotency for replay protection
- Dual-secret rotation with 24-hour overlap
- Customer-facing verification documentation
- Comprehensive test coverage
- Quarterly security audit

The hidden cost of weak webhook security: **catastrophic exploits at scale.** A startup with 1000 customers and weak verification has 1000 attack surfaces. Each customer''s webhook URL is a path to forge events; each forged event is a potential paid-feature unlock or data leak. The cost shows up as "we noticed weird charges" tickets compounding into a security audit and reputation damage. Verification is cheap to add; expensive to omit.

## See Also

- [Outbound Webhooks](outbound-webhooks-chat.md) — sending webhooks
- [Inbound Webhooks](inbound-webhooks-chat.md) — receiving webhooks
- [Idempotency Patterns](idempotency-patterns-chat.md) — replay protection
- [Public API](public-api-chat.md) — adjacent
- [API Keys](api-keys-chat.md) — adjacent auth
- [API Versioning](api-versioning-chat.md) — adjacent
- [Rate Limiting & Abuse](rate-limiting-abuse-chat.md) — adjacent
- [Audit Logs](audit-logs-chat.md) — log webhook events
- [Caching Strategies](caching-strategies-chat.md) — adjacent
- [Service Level Agreements](service-level-agreements-chat.md) — uptime depends
- [VibeReference: Stripe](https://www.vibereference.com/auth-and-payments/stripe) — Stripe webhook signing
- [VibeReference: Secret Management Providers](https://www.vibereference.com/devops-and-tools/secret-management-providers) — secret storage
- [VibeReference: Bot Detection Providers](https://www.vibereference.com/devops-and-tools/bot-detection-providers) — adjacent defense
- [VibeReference: API Documentation Tools](https://www.vibereference.com/backend-and-data/api-documentation-tools) — document signing for customers

[⬅️ Day 6: Grow Overview](README.md)