Tools: Webhook Security Best Practices for Production 2025-2026

Tools: Webhook Security Best Practices for Production 2025-2026

Source: Dev.to

Webhook Security Best Practices for Production ## Verify Signatures. Every Time. ## Prevent SSRF Attacks ## How to Prevent It ## Rate Limit Your Endpoint ## Validate Payload Structure ## Enforce HTTPS ## Limit Payload Size ## Timestamp Validation ## Log Everything, Expose Nothing ## Security Checklist ## Resources 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. 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. 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. 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. Block requests to private IP ranges at the network level. Don't just check the hostname — resolve it first, then check the IP. 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. 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. Even with signature verification, your endpoint should have rate limits. Why? A simple approach using a token bucket or sliding window per source IP: 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. Don't trust the payload blindly just because the signature is valid. Signatures prove authenticity, not correctness. 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: Parameterized queries protect against SQL injection regardless, but validating the shape of the data catches bugs early and makes your handler more predictable. 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: 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. 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. 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. 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 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. 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. For every webhook endpoint going to production: None of these are difficult individually. The danger is skipping them during the "just get it working" phase and never going back. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK: 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)) } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: 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)) } CODE_BLOCK: 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)) } CODE_BLOCK: 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 } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: 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 } CODE_BLOCK: 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 } CODE_BLOCK: 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} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: 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} CODE_BLOCK: 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} COMMAND_BLOCK: 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(): # ... Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: 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(): # ... COMMAND_BLOCK: 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(): # ... COMMAND_BLOCK: 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]); }); Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: 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]); }); COMMAND_BLOCK: 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]); }); COMMAND_BLOCK: 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]); }); Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: 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]); }); COMMAND_BLOCK: 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]); }); COMMAND_BLOCK: 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; } } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: 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; } } COMMAND_BLOCK: 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; } } CODE_BLOCK: 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... } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: 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... } CODE_BLOCK: 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... } CODE_BLOCK: app.post('/webhooks/stripe', express.raw({ type: 'application/json', limit: '1mb' }), handler ); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: app.post('/webhooks/stripe', express.raw({ type: 'application/json', limit: '1mb' }), handler ); CODE_BLOCK: app.post('/webhooks/stripe', express.raw({ type: 'application/json', limit: '1mb' }), handler ); COMMAND_BLOCK: 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 } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: 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 } COMMAND_BLOCK: 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 } CODE_BLOCK: 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), ) Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: 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), ) CODE_BLOCK: 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), ) - Webhook replay (user provides target URL) - Webhook relay destinations - Custom webhook verification callbacks - OAuth redirect URLs - 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. - [ ] 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 - OWASP Server-Side Request Forgery Prevention - Stripe Webhook Signature Verification - GitHub Securing Your Webhooks - Shopify Webhook Verification - OWASP Input Validation Cheat Sheet