Security·10 min read

Webhook Security: How to Verify Signatures and Protect Your Endpoints

Your webhook endpoint is a public URL. Anyone who discovers it can send a POST request with a fake payload. Webhook security isn't optional — it's the difference between a production-ready integration and a liability. Here's how to lock it down.

Webhook security and signature verification illustration

Why Webhook Endpoints Are Vulnerable

When you register a webhook endpoint with a provider, you're giving them a public URL. But "public" means exactly that — anyone with the URL can send requests to it. There's no built-in authentication in the HTTP protocol to prove who sent a request.

This creates real attack vectors:

  • Replay attacks. An attacker intercepts a legitimate webhook payload and resends it to your endpoint, causing your system to process the same event twice — potentially issuing duplicate refunds, sending duplicate emails, or double-counting revenue.
  • Forgery. An attacker crafts a fake payload that looks like a legitimate webhook delivery. If your handler doesn't verify the source, it'll process the forged data. Imagine a fake payment_intent.succeeded event granting access to a product that was never paid for.
  • Data injection. Malicious payloads can include unexpected fields, oversized data, or carefully crafted values designed to exploit parsing logic, trigger SQL injection, or cause denial of service.

These aren't hypothetical. If your endpoint handles payments, subscriptions, or access control, an unverified webhook is a direct path to financial loss or unauthorized access.

How Webhook Signature Verification Works

The industry-standard defense is HMAC (Hash-based Message Authentication Code) signature verification. Here's the mechanism in plain terms.

When you set up a webhook with a provider, they give you a webhook secret — a shared key known only to you and the provider. When the provider sends a webhook, they take the raw request body, hash it using the secret key (usually with SHA-256), and include the resulting signature in a request header.

Your handler does the same computation: take the raw body, hash it with the same secret, and compare your computed signature against the one in the header. If they match, the request is authentic. If they don't, someone else sent it.

# The core verification logic (pseudocode)
expected_signature = HMAC-SHA256(webhook_secret, raw_request_body)
received_signature = request.headers["X-Signature-256"]

if secure_compare(expected_signature, received_signature):
    process_event(request.body)
else:
    return 401 Unauthorized

Note the secure_compare — you must use a timing-safe comparison function, not a standard string equality check. Standard equality checks can leak information about the expected signature through timing differences, enabling a side-channel attack.

Implementing Signature Verification: Real Examples

Every major webhook provider uses the same principle but with slightly different header names and hashing details. Here are working implementations for the most common providers.

Stripe Webhook Signature (Node.js)

Stripe sends the signature in the Stripe-Signature header. Their official library handles verification for you:

const stripe = require('stripe')('sk_live_xxx');
const endpointSecret = 'whsec_xxx'; // From your Stripe dashboard

app.post('/webhooks/stripe', express.raw({type: 'application/json'}), (req, res) => {
  const sig = req.headers['stripe-signature'];

  try {
    const event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
    // event is verified — safe to process
    handleStripeEvent(event);
    res.status(200).json({received: true});
  } catch (err) {
    console.error('Signature verification failed:', err.message);
    res.status(401).json({error: 'Invalid signature'});
  }
});

Critical detail: You must pass the raw request body to the verification function, not a parsed JSON object. If Express parses the body before you verify it, the signature check will fail because JSON serialization can change whitespace and key ordering. Use express.raw() as the body parser for your webhook route.

GitHub Webhook Signature (Python)

GitHub sends the signature in the X-Hub-Signature-256 header using HMAC-SHA256:

import hmac
import hashlib

WEBHOOK_SECRET = b'your_github_webhook_secret'

def verify_github_signature(payload_body, signature_header):
    if not signature_header:
        return False

    expected = 'sha256=' + hmac.new(
        WEBHOOK_SECRET,
        payload_body,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature_header)

# In your request handler
@app.route('/webhooks/github', methods=['POST'])
def github_webhook():
    signature = request.headers.get('X-Hub-Signature-256')
    if not verify_github_signature(request.data, signature):
        abort(401)

    event = request.json
    # Signature verified — safe to process
    handle_github_event(event)
    return '', 200

Notice hmac.compare_digest — this is Python's built-in timing-safe comparison. Never use == for signature comparison.

Shopify Webhook Signature (Node.js)

Shopify sends the HMAC in the X-Shopify-Hmac-Sha256 header, base64-encoded:

const crypto = require('crypto');

const SHOPIFY_SECRET = 'your_shopify_webhook_secret';

function verifyShopifyWebhook(rawBody, hmacHeader) {
  const computed = crypto
    .createHmac('sha256', SHOPIFY_SECRET)
    .update(rawBody, 'utf8')
    .digest('base64');

  return crypto.timingSafeEqual(
    Buffer.from(computed),
    Buffer.from(hmacHeader)
  );
}

app.post('/webhooks/shopify', express.raw({type: 'application/json'}), (req, res) => {
  const hmac = req.headers['x-shopify-hmac-sha256'];

  if (!verifyShopifyWebhook(req.body, hmac)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body);
  handleShopifyEvent(event);
  res.status(200).send('OK');
});

Beyond Signatures: Additional Security Layers

HMAC verification is the foundation, but a fully secure webhook endpoint has additional layers. Think of these as defense in depth — each one catches threats that the others might miss.

Timestamp Validation

Most providers include a timestamp in the webhook delivery. Check that the timestamp is recent — within 5 minutes of the current time. This prevents replay attacks where an attacker resends a captured legitimate payload hours or days later.

// Stripe includes the timestamp in the Stripe-Signature header
// Their SDK handles this automatically, rejecting events older than 5 minutes
// If you're verifying manually:
const tolerance = 300; // 5 minutes in seconds
const timestamp = parseInt(signatureHeader.split(',')[0].split('=')[1]);
const now = Math.floor(Date.now() / 1000);

if (Math.abs(now - timestamp) > tolerance) {
  return res.status(401).send('Timestamp too old');
}

Idempotency

Webhook providers retry failed deliveries. Your handler will receive the same event more than once — it's a guarantee, not an edge case. Every handler must be idempotent: processing the same event ID twice should produce no additional side effects.

The standard approach: store processed event IDs in a database or cache. Before processing any event, check if you've already seen that ID.

app.post('/webhooks/stripe', async (req, res) => {
  const event = verifyAndParse(req);

  // Check for duplicate
  const alreadyProcessed = await db.webhookEvents.findOne({ eventId: event.id });
  if (alreadyProcessed) {
    return res.status(200).json({ received: true, duplicate: true });
  }

  // Mark as processing
  await db.webhookEvents.insert({ eventId: event.id, receivedAt: new Date() });

  // Process the event
  await handleEvent(event);
  res.status(200).json({ received: true });
});

IP Allowlisting

Some providers publish the IP addresses they send webhooks from. If you can restrict your endpoint to only accept traffic from those IPs, you add another layer of protection. Stripe, GitHub, and Twilio all publish their webhook IP ranges in their documentation.

This isn't a replacement for signature verification — IPs can be spoofed in some network configurations. But it does reduce the attack surface, especially when combined with HMAC checks.

Rate Limiting

Even with signature verification, your endpoint can be abused by brute-force attacks — an attacker flooding your URL with requests to exhaust compute resources or attempt signature collisions. Apply rate limiting to your webhook routes: a legitimate provider won't send thousands of events per second, so a limit of 100–500 requests per minute is usually safe.

HTTPS Only

Always register HTTPS endpoints. HTTP endpoints transmit payloads and signatures in plaintext, making them vulnerable to man-in-the-middle attacks. Every major provider requires or strongly recommends HTTPS. There's no reason to accept webhook deliveries over unencrypted connections.

Testing Your Webhook Security

Building signature verification is one thing. Knowing it actually works is another. Here's how to verify your security implementation before going live.

Start by inspecting real webhook deliveries with a tool like CatchHooks. It shows you every header the provider sends — including the signature header and timestamp — so you can see exactly what your verification code needs to parse. This is faster than reading documentation and eliminates guesswork about header names and formats.

Then test the failure cases explicitly:

  • Send a request with no signature header. Your handler should reject it with 401.
  • Send a request with an invalid signature. Modify one character in the HMAC value. Your handler should reject it.
  • Send a request with an old timestamp. Set the timestamp to an hour ago. Your handler should reject it as a potential replay.
  • Send a duplicate event ID. Fire the same event twice. Your handler should process it once and ignore the second delivery.
  • Send a valid request. Confirm your handler accepts it and processes correctly.

If all five pass, your webhook authentication layer is solid. For a step-by-step walkthrough of the full testing process, see our guide on how to test a webhook.

Quick Reference: Provider Signature Headers

ProviderSignature HeaderAlgorithmEncoding
StripeStripe-SignatureHMAC-SHA256Hex
GitHubX-Hub-Signature-256HMAC-SHA256Hex (prefixed with sha256=)
ShopifyX-Shopify-Hmac-Sha256HMAC-SHA256Base64
TwilioX-Twilio-SignatureHMAC-SHA1Base64
PayPalPAYPAL-TRANSMISSION-SIGSHA256withRSABase64

Note that PayPal uses asymmetric cryptography (RSA) rather than HMAC. Their verification process involves downloading PayPal's public certificate and verifying the signature against it. The principle is the same — prove the request is authentic — but the implementation differs.

Webhook Security Checklist

Before deploying any webhook handler to production, run through this list. Each item addresses a specific attack vector.

  • Verify the HMAC signature on every request. No exceptions. Reject anything that doesn't match with a 401.
  • Use timing-safe comparison. Standard string equality is vulnerable to timing attacks. Use crypto.timingSafeEqual (Node.js), hmac.compare_digest (Python), or your language's equivalent.
  • Validate timestamps. Reject events older than 5 minutes to prevent replay attacks.
  • Implement idempotency. Track processed event IDs and skip duplicates. This protects against both retries and replay attacks.
  • Parse the raw body for verification. Verify against the raw bytes, not the parsed JSON. Body parsers can change formatting and break signature checks.

Frequently Asked Questions

What is webhook signature verification?

It's a method for proving that a webhook request was actually sent by the provider it claims to be from. The provider computes an HMAC hash of the request body using a shared secret key, and includes the hash in a request header. Your server performs the same computation with the same secret and compares the results. If they match, the request is authentic. If they don't, someone else sent it and your handler should reject it.

Do I really need to verify webhook signatures?

Yes. Your webhook endpoint is a public URL — anyone who discovers it can send a POST request with a crafted payload. Without signature verification, your handler has no way to distinguish a legitimate delivery from Stripe from a forged one. If your endpoint processes payments, updates user access, or modifies data, an unverified webhook is a security vulnerability.

What is a webhook secret?

A webhook secret is a shared key between you and the provider. The provider uses it to compute the HMAC signature of each webhook payload. You store it securely in your application (as an environment variable, not hardcoded) and use it to compute and verify signatures on the receiving end. Each provider gives you this secret when you configure a webhook endpoint — in Stripe, it starts with whsec_; in GitHub, you set it yourself during webhook creation.

What happens if I don't verify webhook signatures?

Your endpoint will process any request that arrives, regardless of who sent it. An attacker could forge a payment_intent.succeeded event to gain free access to a paid product. They could submit fake order data to your system. They could trigger automated actions — emails, API calls, database writes — with completely fabricated payloads. The risk scales with what your handler does: if it only logs data, the damage is limited. If it processes payments or grants access, it's critical.

How can I inspect webhook headers to debug signature verification?

Use a webhook testing tool like CatchHooks to capture a real delivery. It displays the complete request including all headers — you'll see the exact signature header name, format, and value the provider sends. This is the fastest way to confirm your verification code is parsing the right header with the right encoding, before debugging anything in your own handler.