Engineering

Webhook Security: Signature Verification, Replay Protection, and Retry Logic

May 2026 · 11 min read

Most webhook endpoints in production fail at least one of: signature verification, replay protection, idempotency, or retry handling. Each failure mode is a different attack surface or reliability bug. Here's the full pattern that handles all four for a Stripe-style webhook receiver.

1. Signature verification (HMAC-SHA256)

The sender (Stripe, GitHub, custom) signs the request body with a shared secret using HMAC-SHA256 and puts the result in a header (Stripe-Signature, X-Hub-Signature-256, etc.). Your endpoint must verify the signature before trusting any payload data.

Python:

import hmac, hashlib

def verify_signature(secret: bytes, body: bytes, signature_header: str) -> bool:
    expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header)

Critical: use hmac.compare_digest, never ==. Timing attacks against naive string comparison have been demonstrated in the wild.

Verify against the raw request body bytes, not the parsed JSON. Different JSON serializers produce different byte sequences for the same logical payload.

2. Replay protection (timestamp window)

Even a valid signature can be replayed by an attacker who captured a previous request. Add a timestamp to the signed payload and reject anything older than 300 seconds (Stripe's default).

import time

def is_fresh(timestamp_header: str, max_age_seconds: int = 300) -> bool:
    sent_at = int(timestamp_header)
    now = int(time.time())
    return abs(now - sent_at) <= max_age_seconds

The timestamp must be part of what's signed — otherwise the attacker just changes the timestamp header. Stripe's signature scheme: signed_payload = "{timestamp}.{body}".

3. Idempotency

Webhook senders retry on failure. Your handler must produce the same outcome whether called once or seventeen times for the same event.

Pattern: every webhook delivery carries a unique event ID. Store processed event IDs in a database with a unique index. On every incoming webhook, attempt to insert the event ID; if the insert fails (duplicate), return 200 OK immediately without processing.

def handle_webhook(event_id: str, payload: dict):
    try:
        db.execute(
            "INSERT INTO processed_events (event_id, received_at) VALUES (%s, NOW())",
            (event_id,)
        )
    except UniqueViolation:
        return 200  # already processed, idempotent ack

    process_business_logic(payload)
    return 200

Retention: keep event IDs for at least the sender's retry window (Stripe: 3 days, GitHub: 8 hours). After that, prune to keep the table small.

4. Retry logic (when YOU are the sender)

If your service sends webhooks to customers, you need retry logic that doesn't hammer their endpoints. Standard pattern: exponential backoff with capped retries.

retry_delays = [1, 2, 4, 8, 16]  # seconds, doubling
max_retries = 5

for attempt, delay in enumerate(retry_delays):
    response = send_webhook(url, payload)
    if response.status_code == 2xx:
        break
    if response.status_code in (4xx except 429):
        break  # client error, won't fix itself
    time.sleep(delay + random.uniform(0, 1))  # jitter
else:
    mark_webhook_dead(payload_id)

Honor 429 (rate limit) responses by reading the Retry-After header. Don't retry 4xx errors other than 429 — they indicate a client bug that retries won't fix.

5. Order of operations in the handler

  1. Read raw body bytes (do NOT parse JSON yet)
  2. Verify signature against raw body
  3. Check timestamp freshness (reject if older than 300s)
  4. Parse JSON
  5. Extract event ID
  6. Insert event ID into processed_events (idempotency check)
  7. If duplicate: return 200 immediately, skip processing
  8. Process business logic
  9. Return 200 (or 500 if processing failed and you want a retry)

Steps 1-5 must complete in under 1 second. Step 8 can be slow if you queue it to a worker.

6. Common mistakes

  • Parsing JSON before signature verification. Attacker payloads can crash your JSON parser before you check whether they're authentic.
  • Using string equality instead of hmac.compare_digest. Timing attack vulnerable.
  • Storing the secret in source code. Use env vars or a secret manager.
  • Returning 200 on processing failures. The sender thinks delivery succeeded and won't retry.
  • Returning 500 on duplicate events. The sender retries forever; pick 200.
  • Processing synchronously when the work takes more than a few seconds. Senders time out at 10-30 seconds. Queue the work.

7. Testing

Use Stripe CLI's stripe trigger command or ngrok + the sender's dashboard "Send test webhook" feature. Verify your endpoint:

  • Rejects invalid signatures with 401
  • Rejects stale timestamps with 400
  • Returns 200 on duplicate events without reprocessing
  • Returns 200 on first successful processing
  • Returns 500 on processing errors (and the sender retries)

Comparison

Concern Mechanism Header / field What to do on failure
Authenticity HMAC-SHA256 Stripe-Signature, X-Hub-Signature-256 401 Unauthorized
Replay protection Timestamp + signature Stripe-Signature t=... 400 Bad Request
Idempotency Event ID + unique index event.id in payload 200 OK (silent dedupe)
Retries (as sender) Exponential backoff + jitter n/a 5 attempts then dead-letter
Retries (as receiver) Return 2xx on success, 5xx for retry n/a Sender retries 5xx automatically

FAQ

Why HMAC-SHA256 instead of SHA1?
SHA1 has known collision attacks. SHA256 is the current standard. Modern webhook senders (Stripe, GitHub, Shopify) all use SHA256.

How long should I keep event IDs for dedup?
At least the sender's retry window: Stripe 3 days, GitHub 8 hours, Shopify 4 days. We default to 7 days for safety, then prune.

Should I queue webhook processing?
Yes if business logic takes more than 2 seconds. Endpoint accepts the webhook (verify, dedupe, queue), returns 200. Worker processes the actual work. Prevents sender timeouts.

What if my queue worker fails?
The work is in the queue, retries by your queue's policy (typically 3-5 attempts). The webhook sender already got 200 — they won't retry, but your queue will retry your processing.

Need webhook integration done right?

We build production webhook handlers with all four protections baked in. Stripe, GitHub, custom sources.

Book a discovery call

Related Posts

API Rate Limiting Stripe Webhook Integration
← All blog posts

Four checks separate a working webhook from a broken one

Signature, timestamp, idempotency, retries. Done correctly the first time.

Book a discovery call