← Back to Blog

Webhook Security Best Practices for Production

By ThunderHooks Team ·
webhookssecuritybest-practicesssrfproduction

Webhook Security Best Practices for Production

A webhook endpoint is a publicly accessible URL that accepts arbitrary POST requests from the internet. Read that sentence again. If that doesn't make you a little nervous, it should.

Most webhook tutorials focus on getting things working. Parse the JSON, handle the event, return 200. But a webhook endpoint in production is an attack surface. Without proper security, it's an open door.

Verify Signatures. Every Time.

This is the single most important thing. Every major webhook provider signs their payloads — Stripe, GitHub, Shopify, Twilio, Slack. The signature proves the request actually came from them and wasn't tampered with in transit.

The pattern is always the same: the provider computes an HMAC of the request body using a shared secret, sends the signature in a header, and you recompute the HMAC on your end and compare.

Skip this and anyone can POST fake events to your endpoint. A forged payment_intent.succeeded event could grant access to someone who never paid. A forged customer.subscription.deleted could revoke a paying customer's access.

func verifyStripeSignature(payload []byte, sigHeader string, secret string) error {
    return stripe.VerifySignature(payload, sigHeader, secret)
}

func verifyGitHubSignature(payload []byte, sigHeader string, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(payload)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(sigHeader))
}

Two things people get wrong:

Comparing signatures with == instead of constant-time comparison. Regular string equality short-circuits — it returns false as soon as it finds a mismatched character. An attacker can time the responses to figure out the signature byte by byte. Use hmac.Equal (Go), crypto.timingSafeEqual (Node), or hmac.compare_digest (Python).

Parsing the body before verifying the signature. The signature is computed against the raw bytes. If your web framework parses JSON before your middleware runs, the reparsed output might have different whitespace or key ordering. Always read the raw body first, verify, then parse. In Express this means express.raw() on the webhook route. In Django, use request.body not request.data.

Prevent SSRF Attacks

Here's a scenario that bit us when we were building ThunderHooks.

You build a webhook replay feature. User provides a URL, your server sends an HTTP request to it. Seems straightforward. But what if the user enters http://169.254.169.254/latest/meta-data/? That's the AWS metadata endpoint. Your server just fetched IAM credentials and returned them to the user.

Or http://localhost:6379/ — now they're talking to your Redis instance. Or http://10.0.0.5:5432/ — your internal Postgres. Any feature where your server makes HTTP requests to user-provided URLs is a potential SSRF (Server-Side Request Forgery) vector.

This applies to:

  • Webhook replay (user provides target URL)
  • Webhook relay destinations
  • Custom webhook verification callbacks
  • OAuth redirect URLs

How to Prevent It

Block requests to private IP ranges at the network level. Don't just check the hostname — resolve it first, then check the IP.

func isSafeURL(targetURL string) error {
    parsed, err := url.Parse(targetURL)
    if err != nil {
        return fmt.Errorf("invalid URL")
    }

    // Must be HTTPS (or HTTP for local dev)
    if parsed.Scheme != "https" && parsed.Scheme != "http" {
        return fmt.Errorf("unsupported scheme: %s", parsed.Scheme)
    }

    // Resolve hostname to IP
    ips, err := net.LookupIP(parsed.Hostname())
    if err != nil {
        return fmt.Errorf("DNS resolution failed")
    }

    for _, ip := range ips {
        if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
            return fmt.Errorf("target resolves to private IP")
        }
    }

    return nil
}

But this has a race condition. DNS can return a different IP between your check and the actual HTTP request (a technique called DNS rebinding). The proper fix is validating at the transport level — during the TCP dial, not before it.

transport := &http.Transport{
    DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
        host, port, _ := net.SplitHostPort(addr)
        ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
        if err != nil {
            return nil, err
        }

        for _, ip := range ips {
            if ip.IP.IsLoopback() || ip.IP.IsPrivate() || ip.IP.IsLinkLocalUnicast() {
                return nil, fmt.Errorf("blocked: %s resolves to %s", host, ip.IP)
            }
        }

        // Dial with the resolved IP, not the hostname
        dialer := &net.Dialer{Timeout: 10 * time.Second}
        return dialer.DialContext(ctx, network, addr)
    },
}

client := &http.Client{Transport: transport}

This is the approach ThunderHooks uses. Every outgoing request — replays, relays, monitor checks — goes through a transport that validates the destination IP at dial time. No TOCTOU race, no DNS rebinding.

Rate Limit Your Endpoint

Even with signature verification, your endpoint should have rate limits. Why?

  • Replay attacks. A valid signed request intercepted in transit can be resent repeatedly. Rate limiting bounds the damage.
  • Accidental loops. A misconfigured webhook that triggers itself can generate thousands of requests per minute.
  • Provider retries during outages. If your handler returns 500 during a deploy, the webhook provider starts retrying. Combined with normal traffic, this can overwhelm your server during recovery.

A simple approach using a token bucket or sliding window per source IP:

from flask_limiter import Limiter

limiter = Limiter(app, key_func=get_remote_address)

@app.route('/webhooks/stripe', methods=['POST'])
@limiter.limit("60/minute")
def stripe_webhook():
    # ...

Be careful with the limit. Stripe can send bursts during batch operations — a subscription migration might fire hundreds of events in seconds. Set the limit high enough for legitimate spikes but low enough to catch abuse. Start with 100/minute and adjust based on your actual traffic patterns.

Validate Payload Structure

Don't trust the payload blindly just because the signature is valid. Signatures prove authenticity, not correctness.

app.post('/webhooks/stripe', (req, res) => {
  const event = JSON.parse(req.body);

  // Don't do this
  const amount = event.data.object.amount;
  await db.query('UPDATE orders SET amount = $1 WHERE id = $2', [amount, event.data.object.metadata.order_id]);
});

What if event.data.object.metadata.order_id contains '; DROP TABLE orders; --? The signature is valid — Stripe really sent that payload — but the metadata came from user input on your checkout page.

Validate and sanitize:

app.post('/webhooks/stripe', (req, res) => {
  const event = JSON.parse(req.body);

  if (event.type !== 'payment_intent.succeeded') {
    return res.status(200).end();
  }

  const orderId = event.data.object.metadata?.order_id;
  if (!orderId || typeof orderId !== 'string' || orderId.length > 64) {
    console.error('Invalid order_id in webhook metadata');
    return res.status(200).end(); // Don't retry
  }

  // Use parameterized queries (always)
  await db.query('UPDATE orders SET paid = true WHERE id = $1', [orderId]);
});

Parameterized queries protect against SQL injection regardless, but validating the shape of the data catches bugs early and makes your handler more predictable.

Enforce HTTPS

Webhook payloads contain sensitive data — payment amounts, customer emails, API keys in headers. Sending this over plain HTTP means anyone on the network path can read it.

Every serious webhook provider requires HTTPS endpoints. Stripe flat-out rejects HTTP URLs. GitHub warns you but technically allows it.

In production, there's no reason to accept webhook traffic over HTTP. If you're running behind a load balancer or reverse proxy that terminates TLS:

server {
    listen 80;
    server_name api.yourapp.com;

    # Redirect everything to HTTPS
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl;
    server_name api.yourapp.com;

    ssl_certificate /etc/letsencrypt/live/api.yourapp.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.yourapp.com/privkey.pem;

    location /webhooks/ {
        proxy_pass http://localhost:3000;
    }
}

Monitor your certificate expiry. An expired cert means webhook providers get TLS errors and your integrations silently break. Let's Encrypt certs expire every 90 days — make sure auto-renewal is working and alerting if it fails.

Limit Payload Size

A webhook endpoint that accepts arbitrarily large payloads is asking for trouble. An attacker (or a buggy provider) could send a multi-gigabyte request body and exhaust your server's memory.

Set a reasonable maximum. Most webhook payloads are under 100KB. Stripe's largest payloads (for events like invoice.finalized with many line items) rarely exceed 500KB.

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    // Limit to 1MB
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20)

    payload, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
        return
    }

    // proceed...
}

In Express:

app.post('/webhooks/stripe',
  express.raw({ type: 'application/json', limit: '1mb' }),
  handler
);

Timestamp Validation

Stripe includes a timestamp in the signature header (t=1234567890). You should verify this timestamp is recent — within five minutes, say.

Why? Without timestamp validation, an attacker who captures a valid signed request can replay it hours, days, or weeks later. The signature is still valid because the secret hasn't changed. But the timestamp check catches it.

func verifyStripeTimestamp(sigHeader string) error {
    // Parse timestamp from "t=123,v1=abc..." format
    parts := strings.Split(sigHeader, ",")
    var timestamp int64
    for _, part := range parts {
        if strings.HasPrefix(part, "t=") {
            ts, err := strconv.ParseInt(strings.TrimPrefix(part, "t="), 10, 64)
            if err != nil {
                return fmt.Errorf("invalid timestamp")
            }
            timestamp = ts
        }
    }

    tolerance := int64(300) // 5 minutes
    now := time.Now().Unix()
    if now - timestamp > tolerance {
        return fmt.Errorf("timestamp too old: %d seconds", now - timestamp)
    }

    return nil
}

Stripe's official libraries do this automatically if you use stripe.webhooks.constructEvent(). Don't skip it by rolling your own verification without the timestamp check.

Log Everything, Expose Nothing

Log every incoming webhook — event type, delivery ID, timestamp, processing result, and any errors. You'll need this when debugging "why didn't we process that payment?"

But be careful what you log. Webhook payloads can contain PII (names, emails, addresses) and sensitive financial data (payment amounts, card last four digits). Your logging strategy needs to account for this.

log.Info("webhook received",
    "event_type", event.Type,
    "event_id", event.ID,
    "delivery_id", r.Header.Get("X-GitHub-Delivery"),
    // DON'T log the full payload in production
    // "payload", string(payload),
)

In development, log everything. In production, log metadata (event type, IDs, timestamps) but not the full payload. If you need to inspect production payloads for debugging, use a secure audit log with access controls — not stdout that goes to a shared Datadog instance everyone can query.

Security Checklist

For every webhook endpoint going to production:

  • Signature verification with constant-time comparison
  • Raw body access before JSON parsing
  • SSRF protection on any outbound requests (replay, relay)
  • Rate limiting per source IP
  • Payload size limit (1MB is a safe default)
  • Timestamp validation (Stripe) or equivalent replay protection
  • HTTPS only, with certificate monitoring
  • Input validation on user-controlled fields within payloads
  • Parameterized queries for any database operations
  • Structured logging without PII in production
  • Idempotency keys to handle duplicate deliveries

None of these are difficult individually. The danger is skipping them during the "just get it working" phase and never going back.

Resources

Ready to simplify webhook testing?

Try ThunderHooks free. No credit card required.

Get Started Free