Verifying Webhook Signatures

BackendWebhooksSecurityAPI
Share on LinkedIn

An attacker found our webhook URL in a client-side error log and POSTed a fake order.completed event. Our handler processed it — no signature check, no authentication. One line of middleware verifying HMAC-SHA256 would have rejected it. We added signature verification that day and rotated the webhook secret as a precaution.

HMAC signature scheme

Most providers use HMAC-SHA256:

signature = HMAC-SHA256(secret, timestamp + "." + raw_body)

The sender includes the signature and timestamp in headers. The receiver recomputes and compares.

Stripe-style verification

import hmac
import hashlib
import time

def verify_stripe_signature(payload: bytes, sig_header: str, secret: str, tolerance: int = 300):
    elements = dict(item.split("=", 1) for item in sig_header.split(","))
    timestamp = int(elements["t"])
    received_sig = elements["v1"]

    # Reject stale timestamps
    if abs(time.time() - timestamp) > tolerance:
        raise WebhookError("Timestamp outside tolerance window")

    # Compute expected signature
    signed_payload = f"{timestamp}.".encode() + payload
    expected_sig = hmac.new(
        secret.encode(), signed_payload, hashlib.sha256
    ).hexdigest()

    # Constant-time comparison
    if not hmac.compare_digest(expected_sig, received_sig):
        raise WebhookError("Invalid signature")

    return True

Generic HMAC verification

For custom webhooks or providers using simpler schemes:

def verify_hmac_signature(payload: bytes, signature: str, secret: str):
    expected = hmac.new(
        secret.encode(), payload, hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(f"sha256={expected}", signature)

Header format varies by provider:

Provider Header Format
Stripe Stripe-Signature t=timestamp,v1=sig
GitHub X-Hub-Signature-256 sha256=hex_sig
Shopify X-Shopify-Hmac-Sha256 base64 sig
Svix svix-signature v1,base64_sig

Express middleware

function webhookVerifier(secret) {
  return (req, res, next) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];
    const rawBody = req.rawBody; // capture before JSON parsing

    if (!signature || !timestamp) {
      return res.status(401).json({ error: 'Missing signature headers' });
    }

    const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
    if (age > 300) {
      return res.status(401).json({ error: 'Timestamp too old' });
    }

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

    if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    next();
  };
}

// Capture raw body before JSON middleware
app.use('/webhooks', express.raw({ type: 'application/json' }));
app.use('/webhooks', webhookVerifier(process.env.WEBHOOK_SECRET));
app.use('/webhooks', (req, res, next) => {
  req.body = JSON.parse(req.body);
  next();
});

Raw body capture

Framework-specific raw body access:

# FastAPI
@app.post("/webhooks")
async def handle_webhook(request: Request):
    body = await request.body()  # raw bytes
    signature = request.headers.get("X-Webhook-Signature")
    verify_hmac(body, signature, SECRET)
    payload = json.loads(body)
# Rails — skip params parsing for webhook routes
class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    payload = request.raw_post
    verify_signature(payload, request.headers["X-Signature"])
    process(JSON.parse(payload))
  end
end

Secret management

def verify_with_rotation(payload, signature, timestamp):
    for secret in [current_secret, previous_secret]:
        try:
            return verify_signature(payload, signature, secret, timestamp)
        except WebhookError:
            continue
    raise WebhookError("No valid secret matched")

Security checklist

  1. Verify signature on every request — no exceptions for "testing"
  2. Use raw body bytes — never re-serialize parsed JSON
  3. Validate timestamp — reject replays outside tolerance window
  4. Constant-time comparison — use hmac.compare_digest or crypto.timingSafeEqual
  5. Return generic 401 — don't leak whether timestamp or signature failed
  6. Log verification failures — alert on repeated failures from same IP
  7. Rotate secrets — support dual-secret during rotation windows

Clock skew tolerance

Set timestamp tolerance to 5 minutes (300 seconds) — enough for minor clock drift, short enough to limit replay windows. Log rejected timestamps with the delta for debugging NTP issues on receiver servers.

Testing signature verification

Generate test vectors in CI:

def test_signature_roundtrip():
    payload = b'{"event": "test"}'
    ts = str(int(time.time()))
    sig = compute_signature(payload, ts, TEST_SECRET)
    assert verify(payload, f"t={ts},v1={sig}", TEST_SECRET)

Measuring success in production

Deploy changes behind feature flags when possible so you can compare metrics between control and treatment groups. Use Real User Monitoring to capture performance data from actual devices and network conditions — lab tools alone miss the long tail of user experiences. Set up alerts for regressions: a 10% LCP increase week-over-week warrants investigation before it hits CrUX.

Document your baseline metrics before making changes. Performance work without measurement is guesswork. Share results with the team — concrete numbers ("LCP improved 800ms on mobile") build support for continued investment in web performance and reliability.

Review changes quarterly. Browser updates, new API support, and traffic pattern shifts can obsolete previous optimizations or create new opportunities. What worked in 2024 may not be the best approach in 2026.

Additional production considerations

Teams often underestimate the maintenance cost of performance optimizations. Automate what you can: CI bundle budgets, Lighthouse CI on PRs, and RUM dashboards that alert on regressions. Manual audits don't scale past a handful of pages.

Security and performance intersect more than teams expect. Third-party scripts that hurt INP also expand your attack surface. Self-hosting fonts and critical assets reduces both latency and supply-chain risk. Review every external dependency quarterly — remove what you no longer need.

Accessibility and performance share goals: semantic HTML helps screen readers and gives the browser better rendering hints. Native elements like dialog, popover, and details reduce JavaScript while improving accessibility. Prefer platform features over custom implementations when they meet your requirements.

Mobile users dominate traffic for most sites. Test on real mid-tier Android hardware, not just desktop Chrome. Simulated throttling in DevTools approximates network conditions but not CPU constraints. A fix that helps desktop may be invisible on mobile if the bottleneck is JavaScript execution, not network.

Collaborate with backend teams on TTFB and API response times. Frontend optimizations can't fix a 2-second server response. Set SLAs for API endpoints that feed critical pages and measure them in the same RUM pipeline as Core Web Vitals.

Resources

Frequently asked questions

Why is webhook signature verification necessary?

Webhook endpoints are public URLs. Without signature verification, anyone who discovers the URL can send fake events — creating fraudulent orders, triggering refunds, or modifying account state. Signatures prove the payload came from the expected sender and wasn't modified in transit. Always verify before processing any webhook payload.

Should I verify the signature before or after parsing the JSON body?

Verify before parsing. Read the raw request body as bytes, compute the expected signature, and compare. Then parse JSON. If you parse first and re-serialize, whitespace and key ordering differences will cause signature mismatches. Most frameworks let you access the raw body before JSON middleware processes it.

How do timestamp checks prevent replay attacks?

Include a timestamp in the signed payload. Reject webhooks with timestamps older than your tolerance window (typically 5 minutes). An attacker who captures a valid webhook cannot replay it after the window expires. Combine timestamp validation with idempotency keys for defense in depth against both replay and duplicate delivery.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →