Webhook Security Code Review Guide
Table of Contents
Introduction
A webhook is an anonymous HTTP POST that your server accepts from the public internet and uses to trigger privileged actions: marking a payment as paid, provisioning a user, syncing inventory, or deploying code. Because there is no interactive user behind the request, authentication happens entirely through a shared secret and a signature — and when that verification is wrong, the endpoint becomes an unauthenticated state-mutation API.
Webhooks appear on both sides of your architecture. Inbound webhooks (receiving events from Stripe, GitHub, Shopify, Slack) are about verifying that a request really came from the expected producer. Outbound webhooks (letting your users configure a URL you'll POST to) are about SSRF and data exfiltration. The two have almost opposite threat models, so review them separately.
Webhook endpoints are admin APIs in disguise
A /webhooks/stripe route that marks invoices paid is indistinguishable from a POST /admin/mark-paid endpoint, except that the latter would obviously require authentication. Review webhook handlers with the same rigor you would apply to any unauthenticated mutation endpoint — because that is exactly what they are.
Why are webhook endpoints a uniquely high-value target?
Webhook Attack Surface
Every webhook integration has several distinct components that each carry their own class of bugs: the receiving endpoint, the signature scheme, the shared secret, the event payload, the downstream side effects, and — for outbound webhooks — the URL resolution and HTTP client.
Webhook Delivery Flow
Event occurs → sign payload → POST to subscriber URLMutate state, dispatch jobs, trigger downstream callsKey Insight: A webhook endpoint is an anonymous, public, server-to-server POST that triggers privileged state changes. Without signature verification it is functionally an unauthenticated admin API.
Webhook Security Concerns by Component
| Component | Security Concern | Potential Impact |
|---|---|---|
| Endpoint | Exposed without signature check | Anonymous state mutation |
| Signature | Non-constant-time compare, wrong algorithm | Forgery, timing bypass |
| Timestamp | Missing freshness check | Replay attacks |
| Secret | Hardcoded, shared across tenants | Broad compromise on leak |
| Payload parser | Mass assignment, deserialization | Takeover, RCE |
| Handler | Non-idempotent side effects | Double-charge, duplicate provisioning |
| Outbound URL | No allowlist / DNS rebinding | SSRF, cloud metadata theft |
| Response body | Echoes internal state | Info disclosure to attacker |
A Dangerous Inbound Webhook Handler
1// VULNERABLE: accepts any POST and mutates billing state
2app.post('/webhooks/stripe', async (req, res) => {
3 const event = req.body; // trusts the parsed JSON entirely
4
5 if (event.type === 'invoice.paid') {
6 await db.invoices.update({
7 where: { id: event.data.object.id },
8 data: { status: 'paid' },
9 });
10 }
11
12 res.sendStatus(200);
13});
14
15// Red flags:
16// 1. No signature verification
17// 2. No timestamp / replay check
18// 3. No idempotency key
19// 4. Trusts event.data.object.id from the body
20// 5. Returns 200 even on unknown event types (hides bugs)In the handler above, which single control would most quickly reduce impact?
Signature Verification
Most webhook producers sign requests with HMAC-SHA256 over the raw request body (often concatenated with a timestamp). The receiver recomputes the HMAC with the shared secret and compares it to the header. Getting this wrong — or skipping it — is the single most common webhook vulnerability in real code reviews.
Common Signature Verification Mistakes
1import crypto from 'crypto';
2
3// MISTAKE 1: Using the already-parsed body instead of the raw bytes.
4// JSON.stringify(parsed) reorders/whitespace-changes the payload → HMAC mismatch,
5// so teams often "fix" this by skipping verification.
6app.post('/webhooks/stripe', express.json(), (req, res) => {
7 const expected = crypto
8 .createHmac('sha256', secret)
9 .update(JSON.stringify(req.body)) // WRONG
10 .digest('hex');
11 // ...
12});
13
14// MISTAKE 2: Non-constant-time comparison — leaks bytes via timing.
15if (req.header('X-Signature') === expected) { ... } // WRONG
16
17// MISTAKE 3: Trusting the algorithm field from the header itself.
18const algo = req.header('X-Algo'); // attacker sets "none"
19if (algo === 'none') return next();
20
21// MISTAKE 4: Length-extension attack on raw SHA-256 concat (not HMAC).
22const sig = crypto.createHash('sha256')
23 .update(secret + body) // VULNERABLE TO LENGTH EXTENSION
24 .digest('hex');Safe Verification Pattern
1import crypto from 'crypto';
2import express from 'express';
3
4// Capture the RAW body for HMAC. Do not JSON.parse first.
5app.post(
6 '/webhooks/stripe',
7 express.raw({ type: 'application/json', limit: '1mb' }),
8 (req, res) => {
9 const header = req.header('Stripe-Signature') ?? '';
10 const parts = Object.fromEntries(
11 header.split(',').map((kv) => kv.split('=') as [string, string]),
12 );
13 const timestamp = parts.t;
14 const signature = parts.v1;
15
16 if (!timestamp || !signature) {
17 return res.sendStatus(400);
18 }
19
20 // Reject stale events (see next section)
21 const age = Math.abs(Date.now() / 1000 - Number(timestamp));
22 if (!Number.isFinite(age) || age > 300) {
23 return res.sendStatus(400);
24 }
25
26 const signedPayload = `${timestamp}.${req.body.toString('utf8')}`;
27 const expected = crypto
28 .createHmac('sha256', process.env.STRIPE_WEBHOOK_SECRET!)
29 .update(signedPayload)
30 .digest('hex');
31
32 const a = Buffer.from(expected, 'hex');
33 const b = Buffer.from(signature, 'hex');
34 if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
35 return res.sendStatus(401);
36 }
37
38 // Only NOW parse the body — verification is authentication
39 const event = JSON.parse(req.body.toString('utf8'));
40 return handleEvent(event, res);
41 },
42);Prefer the provider SDK when one exists
Stripe, GitHub, Shopify, and Twilio all ship SDK helpers (stripe.webhooks.constructEvent, verifyWebhook, etc.) that encapsulate raw-body capture, HMAC construction, timing-safe comparison, and timestamp tolerance. Rolling your own signature code is where most bugs creep in. If you must roll your own, always compare with crypto.timingSafeEqual and equal-length buffers.
Python / Go Verification
1# Python (Flask) — use stripe SDK when possible
2import stripe
3from flask import request, abort
4
5@app.post("/webhooks/stripe")
6def stripe_webhook():
7 payload = request.get_data() # raw bytes
8 sig = request.headers.get("Stripe-Signature", "")
9 try:
10 event = stripe.Webhook.construct_event(
11 payload, sig, os.environ["STRIPE_WEBHOOK_SECRET"],
12 tolerance=300, # seconds
13 )
14 except (ValueError, stripe.error.SignatureVerificationError):
15 abort(400)
16 return handle(event)
17
18# Go — stdlib HMAC with subtle.ConstantTimeCompare
19func verify(body []byte, sigHex, secret string) bool {
20 mac := hmac.New(sha256.New, []byte(secret))
21 mac.Write(body)
22 expected := mac.Sum(nil)
23 got, err := hex.DecodeString(sigHex)
24 if err != nil {
25 return false
26 }
27 return len(got) == len(expected) && subtle.ConstantTimeCompare(got, expected) == 1
28}Why does verifying against JSON.stringify(req.body) almost always break?
Replay Attacks & Timestamps
A valid signed webhook is valid forever unless you prevent reuse. Anyone who captures a legitimate request — a malicious proxy, a leaked access log, a copy-pasted debug dump — can replay it repeatedly. A replayed invoice.paid settles the invoice again. A replayed user.created re-provisions an account. Signature verification alone is not enough.
Replay-Vulnerable Handler
1// Signature is verified, but nothing stops an attacker from
2// replaying the same body 100 times and racking up credits.
3app.post('/webhooks/billing', rawBody, (req, res) => {
4 if (!verifySignature(req)) return res.sendStatus(401);
5
6 const event = JSON.parse(req.body.toString('utf8'));
7 if (event.type === 'credit.issued') {
8 creditWallet(event.data.user_id, event.data.amount_cents);
9 }
10 res.sendStatus(200);
11});Timestamp + Replay Cache
1const MAX_AGE_SECONDS = 300; // 5 minutes
2
3async function acceptOnce(eventId: string, ttlSeconds: number) {
4 // SET key value NX EX ttl — atomic "insert if not exists"
5 const ok = await redis.set(
6 `webhook:seen:${eventId}`,
7 '1',
8 'NX',
9 'EX',
10 ttlSeconds,
11 );
12 return ok === 'OK';
13}
14
15app.post('/webhooks/billing', rawBody, async (req, res) => {
16 const ts = Number(req.header('X-Timestamp'));
17 if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > MAX_AGE_SECONDS) {
18 return res.sendStatus(400); // stale or missing
19 }
20
21 if (!verifySignature(req, ts)) return res.sendStatus(401);
22
23 const event = JSON.parse(req.body.toString('utf8'));
24 const fresh = await acceptOnce(event.id, MAX_AGE_SECONDS * 2);
25 if (!fresh) return res.sendStatus(200); // already processed — idempotent ack
26
27 await handleEvent(event);
28 return res.sendStatus(200);
29});Replay Defense Layers
| Layer | What it blocks | Recommended value |
|---|---|---|
| Signature check | Forged events | HMAC-SHA256, timing-safe |
| Timestamp tolerance | Old captures replayed later | 5 minutes |
| Signed timestamp | Attacker rewriting the timestamp | Include ts in HMAC input |
| Event-ID seen-cache | Replays within tolerance window | TTL ≥ 2× tolerance |
| Business idempotency | Double-effects even on distinct events | Per-resource keys |
The timestamp MUST be part of the signed payload
If you only check the timestamp header but sign the body alone, an attacker captures one request, rewrites the X-Timestamp header to "now", and replays. Always HMAC over timestamp + body (that is exactly what Stripe does with "t=...,v1=..."), so tampering with the timestamp invalidates the signature.
Your webhook verifies signature and timestamp (tolerance 5 min) but has no event-ID cache. What remains exploitable?
Outbound Webhook SSRF
Outbound webhooks flip the threat model. Your server is now the HTTP client, and the URL is attacker-controlled. Any platform that lets customers configure "send events to this URL" is a server-side request forgery (SSRF) vector unless the outbound client validates the target, forbids internal address space, follows redirects safely, and defends against DNS rebinding.
Outbound Webhook SSRF Abuse
// User configures webhook: http://169.254.169.254 /latest/meta-data/ iam/security-credentials/
// Webhook points to: http://10.0.0.5/admin /flush-cache // No egress filtering
// evil.com resolves // 1st: 8.8.8.8 (allow) // 2nd: 127.0.0.1 // Bypasses allowlist
Dangerous Outbound Dispatcher
1// VULNERABLE: user-controlled URL, no validation, follows redirects,
2// resolves DNS only once so rebinding works.
3async function dispatch(webhook: { url: string }, event: unknown) {
4 return fetch(webhook.url, {
5 method: 'POST',
6 body: JSON.stringify(event),
7 headers: { 'content-type': 'application/json' },
8 });
9}
10
11// Attacker configures webhook.url = 'http://169.254.169.254/latest/meta-data/...'
12// and the response body (possibly containing cloud creds) is often logged
13// or shown back to them in the UI.Hardened Outbound Client
1import dns from 'dns/promises';
2import net from 'net';
3import ipaddr from 'ipaddr.js';
4
5const PRIVATE_RANGES: [string, number][] = [
6 ['10.0.0.0', 8],
7 ['127.0.0.0', 8],
8 ['169.254.0.0', 16], // link-local + cloud metadata
9 ['172.16.0.0', 12],
10 ['192.168.0.0', 16],
11 ['::1', 128],
12 ['fc00::', 7],
13 ['fe80::', 10],
14];
15
16function isPrivate(ip: string): boolean {
17 const parsed = ipaddr.parse(ip);
18 return PRIVATE_RANGES.some(([range, bits]) => {
19 const r = ipaddr.parse(range);
20 return parsed.kind() === r.kind() && parsed.match(r, bits);
21 });
22}
23
24async function resolveSafe(hostname: string): Promise<string> {
25 const { address } = await dns.lookup(hostname); // single A record
26 if (isPrivate(address) || net.isIP(address) === 0) {
27 throw new Error('blocked target');
28 }
29 return address;
30}
31
32export async function dispatch(url: string, body: string, secret: string) {
33 const u = new URL(url);
34 if (u.protocol !== 'https:') throw new Error('HTTPS required');
35 if (!['', '443'].includes(u.port)) throw new Error('non-standard port blocked');
36
37 const ip = await resolveSafe(u.hostname);
38
39 // Pin the resolved IP, set SNI to the original host — defeats DNS rebinding
40 // because the second DNS lookup inside fetch can no longer flip to 127.0.0.1.
41 const agent = buildPinnedHttpsAgent(ip, u.hostname);
42
43 const sig = signHmac(secret, body);
44
45 return fetch(`https://${u.hostname}${u.pathname}${u.search}`, {
46 method: 'POST',
47 body,
48 headers: {
49 'content-type': 'application/json',
50 'x-signature': sig,
51 'x-timestamp': Math.floor(Date.now() / 1000).toString(),
52 },
53 redirect: 'error', // do NOT follow; a 302 to file:// or internal host is fatal
54 signal: AbortSignal.timeout(5_000),
55 // @ts-expect-error — undici dispatcher / node https agent
56 dispatcher: agent,
57 });
58}Log response metadata, never response bodies
Many outbound-webhook UIs show subscribers the last response body "for debugging." If your client ever hit an internal host, that body is now an exfiltration channel (cloud creds, internal errors with stack traces, private data). Store status code and headers, not the body — or strictly truncate and sanitize.
A hardened dispatcher resolves the hostname and blocks private IPs, then calls fetch() with the original URL. What is still wrong?
Prevention Techniques
Defense-in-depth for webhooks pairs transport-level controls (HTTPS, raw-body capture) with framework-level controls (signature + timestamp + seen-cache middleware) and handler-level controls (idempotency, strict payload validation, minimum-privilege side effects).
Webhook Security Checklist
| Control | Implementation | Priority |
|---|---|---|
| HTTPS-only endpoint | Redirect HTTP → HTTPS, HSTS | Critical |
| Signature verification | HMAC-SHA256 on raw body + timestamp | Critical |
| Timing-safe compare | crypto.timingSafeEqual / subtle.ConstantTimeCompare | Critical |
| Timestamp tolerance | ≤5 minutes, timestamp in signed payload | Critical |
| Event-ID replay cache | Redis SET NX with TTL ≥ 2× tolerance | High |
| Idempotent handler | Keyed on event.id or business key | High |
| Strict payload schema | Zod/JSON-Schema before use | High |
| Outbound SSRF filter | IP allowlist + pinned DNS + redirect off | High |
| Secret scoping | Per-integration / per-tenant secret | High |
| Rate limit per source IP | Blunt DoS and brute force | Medium |
Composable Verification Middleware
1export function verifyWebhook(opts: {
2 secretFor: (req: Request) => Promise<string>;
3 headerName: string;
4 tolerance?: number; // seconds
5}) {
6 return async (req: Request, res: Response, next: NextFunction) => {
7 try {
8 const raw = (req as any).rawBody as Buffer; // set by express.raw()
9 const { t, v1 } = parseHeader(req.header(opts.headerName) ?? '');
10
11 const tolerance = opts.tolerance ?? 300;
12 if (Math.abs(Date.now() / 1000 - Number(t)) > tolerance) {
13 return res.sendStatus(400);
14 }
15
16 const secret = await opts.secretFor(req);
17 const expected = crypto
18 .createHmac('sha256', secret)
19 .update(`${t}.${raw.toString('utf8')}`)
20 .digest();
21 const provided = Buffer.from(v1, 'hex');
22
23 if (expected.length !== provided.length || !crypto.timingSafeEqual(expected, provided)) {
24 return res.sendStatus(401);
25 }
26
27 const event = JSON.parse(raw.toString('utf8'));
28 if (!(await acceptOnce(event.id, tolerance * 2))) {
29 return res.sendStatus(200); // duplicate — ack but do nothing
30 }
31 (req as any).webhookEvent = event;
32 next();
33 } catch {
34 res.sendStatus(400);
35 }
36 };
37}Schema-Validated Handler
1import { z } from 'zod';
2
3const InvoicePaid = z.object({
4 id: z.string().min(1),
5 type: z.literal('invoice.paid'),
6 data: z.object({
7 object: z.object({
8 id: z.string().regex(/^in_[A-Za-z0-9]+$/),
9 amount_paid: z.number().int().nonnegative(),
10 currency: z.string().length(3),
11 }),
12 }),
13});
14
15app.post(
16 '/webhooks/stripe',
17 express.raw({ type: 'application/json', limit: '512kb' }),
18 verifyWebhook({ secretFor: stripeSecret, headerName: 'Stripe-Signature' }),
19 async (req, res) => {
20 const parsed = InvoicePaid.safeParse((req as any).webhookEvent);
21 if (!parsed.success) return res.sendStatus(400); // unexpected shape
22
23 await markInvoicePaid(parsed.data.data.object.id, parsed.data.data.object.amount_paid);
24 return res.sendStatus(200);
25 },
26);Why should the webhook endpoint return 200 for duplicate (already-seen) events?