In March 2026, a critical SSRF vulnerability (CVE-2026-32096, CVSS 9.3) was disclosed in Plunk, an open-source email platform. An unauthenticated attacker could craft a webhook request that forced Plunk's server to make HTTP requests to internal networks — including the AWS metadata endpoint at 169.254.169.254, which returns IAM credentials in plaintext.
Six months earlier, GitLab had disclosed CVE-2025-6454 (CVSS 8.5, High): SSRF through webhook custom headers, allowing authenticated users to reach internal services through proxy environments. Patched in GitLab 18.3.2 in September 2025.
Both vulnerabilities share a root cause: the webhook handler trusted incoming data without validation. Webhook endpoints are public by design — they accept POST requests from the internet. That makes them the most exposed surface in most applications, and the one developers spend the least time hardening.
This post covers three attack vectors against webhook handlers, with code to defend against each one. The testing section at the end covers signature verification, the defense you can automate most easily.
Attack 1: Signature bypass via timing side-channel
Webhook signatures prove the request came from the provider, not an attacker. But the verification code itself can leak information.
The standard string comparison (===) in JavaScript short-circuits: it returns false as soon as it finds a mismatched character. An attacker who can measure response times learns how many leading characters of the signature are correct, then brute-forces the rest one byte at a time.
// ❌ Vulnerable: short-circuit comparison leaks signature bytes
const expected = createHmac("sha256", secret).update(body).digest("hex");
if (signature !== expected) {
return new Response("Invalid signature", { status: 400 });
}// ✅ Fixed: constant-time comparison
import { timingSafeEqual } from "node:crypto";
const expected = createHmac("sha256", secret).update(body).digest("hex");
const valid = timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
if (!valid) {
return new Response("Invalid signature", { status: 400 });
}timingSafeEqual compares every byte regardless of where the first mismatch occurs. The execution time is constant, so response latency reveals nothing about the signature.
timingSafeEqual throws if the two buffers differ in length. Always compare fixed-length outputs (hex or base64 digests), not raw input strings that could vary in size.
What about Stripe's constructEvent?
Stripe's SDK uses constant-time comparison internally. If you call stripe.webhooks.constructEvent(), you're already safe from this attack. The risk applies when you verify signatures yourself — GitHub, Shopify, Twilio, Slack, and any custom provider.
Attack 2: Replay attacks via missing timestamp validation
An attacker intercepts a legitimate signed webhook (from a log, a proxy, or a compromised intermediate). The signature is valid — it was signed by the real provider. The attacker replays it hours or days later.
Without timestamp validation, your handler processes the stale event as if it just happened. In a payment context, this can re-trigger a fulfillment flow.
Some providers include a timestamp in the signature or headers. Others don't:
| Provider | Timestamp location | Replay protection |
|---|---|---|
| Stripe | t= prefix in stripe-signature header | Yes — Stripe's constructEvent rejects events older than 300 seconds |
| Slack | x-slack-request-timestamp header | Yes — but you must validate it yourself |
| Standard Webhooks | webhook-timestamp header | Yes — but you must validate it yourself |
| Shopify | X-Shopify-Triggered-At, X-Shopify-Webhook-Id, X-Shopify-Event-Id headers (none covered by HMAC) | No — headers are present but unsigned, so an attacker can modify them. Use X-Shopify-Webhook-Id for idempotency, not replay prevention |
| GitHub | Not included | No — use idempotency instead |
| Twilio | Not included | No — use idempotency instead |
For providers that include a signed timestamp, reject requests older than 5 minutes:
function validateTimestamp(timestampSeconds: number, toleranceMs = 300_000): boolean {
const age = Math.abs(Date.now() - timestampSeconds * 1000);
return age <= toleranceMs;
}
// Stripe example: extract timestamp from signature header
const parts = signatureHeader.split(",");
const timestamp = Number(parts.find(p => p.startsWith("t="))?.slice(2));
if (!validateTimestamp(timestamp)) {
return new Response("Stale timestamp", { status: 400 });
}Stripe's constructEvent validates the timestamp automatically with a 300-second tolerance. If you use the Stripe SDK, you get replay protection for free. For Slack and Standard Webhooks, you must check the timestamp header yourself — their SDKs verify the signature math but leave timestamp validation to you.
For providers that don't include a signed timestamp (GitHub, Twilio), your defense is idempotency: track processed event IDs and reject duplicates. See our async handler guide for the idempotency pattern.
Attack 3: SSRF via webhook URLs
This is the attack that hit Plunk and GitLab. It applies when your application lets users configure a webhook delivery URL — for example, "send a POST to this URL when an order ships."
If the user enters http://169.254.169.254/latest/meta-data/iam/security-credentials/, your server makes that request and returns the response. The attacker now has your AWS credentials.
Other dangerous targets:
http://localhost:6379/— Redis commands via HTTPhttp://metadata.google.internal/— GCP metadatahttp://192.168.1.1/admin— Internal network deviceshttp://[::1]:3000/— IPv6 loopback bypass
Blocking private IPs
Validate every user-supplied webhook URL before making a request. Resolve the hostname to an IP address and reject anything in private, loopback, or link-local ranges:
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
const BLOCKED_RANGES = [
/^127\./, // IPv4 loopback
/^10\./, // RFC 1918
/^172\.(1[6-9]|2\d|3[01])\./,// RFC 1918
/^192\.168\./, // RFC 1918
/^169\.254\./, // Link-local (AWS metadata)
/^0\./, // "This" network
/^::1$/, // IPv6 loopback
/^f[cd]/i, // IPv6 private
/^fe80:/i, // IPv6 link-local
];
async function isUrlSafe(url: string): Promise<boolean> {
const parsed = new URL(url);
// Block non-HTTP schemes
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
// Resolve hostname to IP
let address: string;
if (isIP(parsed.hostname)) {
address = parsed.hostname;
} else {
const { address: resolved } = await lookup(parsed.hostname);
address = resolved;
}
// Check against blocked ranges
return !BLOCKED_RANGES.some(range => range.test(address));
}This is illustrative — a production implementation should resolve all DNS records (not just the first) and use the resolved IP for the actual HTTP request to prevent DNS rebinding.
DNS rebinding can bypass a resolve-then-fetch check. An attacker registers a domain that first resolves to a public IP (passing your validation), then resolves to 169.254.169.254 when your server actually makes the request. To defend against rebinding, resolve the hostname once, check all returned addresses, and pin the IP for the actual request — don't let the HTTP client re-resolve.
Additional defenses
- Set a response timeout. If the target hangs, your server hangs. Cap outbound requests at 5-10 seconds.
- Don't return the response body to the user. If you must indicate success/failure, return a status code only. Returning the body turns a blind SSRF into a full read SSRF.
- Limit payload size. Cap incoming webhook bodies at 1 MB. Most legitimate webhooks are under 100 KB.
- Rate limit by IP. 60-100 requests per minute per source IP is reasonable for most webhook endpoints.
The checklist
Every webhook handler should pass these checks:
| Check | Protects against | How |
|---|---|---|
| Constant-time signature comparison | Timing side-channel | crypto.timingSafeEqual() |
| Timestamp validation (5-min window) | Replay attacks | Compare provider timestamp to Date.now() |
| Idempotency keys for providers without timestamps | Replay attacks | Track processed event IDs in database |
| Private IP blocking on outbound URLs | SSRF | Resolve hostname, check all addresses against blocked ranges |
| DNS pinning on outbound requests | DNS rebinding | Resolve once, reuse the IP for the request |
| Payload size limit (1 MB) | Resource exhaustion | Check Content-Length before reading body |
| Rate limiting by source IP | Brute-force / DoS | Token bucket or sliding window |
| Non-HTTP scheme rejection | Protocol smuggling | Allow only http: and https: |
Testing your defenses
Security checks that aren't tested will break the first time someone refactors the handler. Use the webhooks.cc SDK to generate signed and unsigned payloads, then send them directly to your handler with fetch and assert on the response.
Test: Reject requests with an invalid signature
Use buildRequest to generate a webhook signed with the wrong secret, then confirm your handler returns 400:
import { WebhooksCC } from "@webhooks-cc/sdk";
import { describe, it, expect } from "vitest";
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
const HANDLER = "http://localhost:3000/api/webhooks/stripe";
describe("webhook security", () => {
it("rejects requests with an invalid signature", async () => {
const signed = await client.buildRequest(HANDLER, {
provider: "stripe",
template: "checkout.session.completed",
secret: "whsec_wrong_secret",
});
const response = await fetch(HANDLER, {
method: signed.method,
headers: signed.headers,
body: signed.body,
});
expect(response.status).toBe(400); // or your rejection code (401, 403)
});
});Test: Reject requests without a signature
Send a plain POST with no signature header at all:
it("rejects requests without a signature", async () => {
const response = await fetch(HANDLER, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "checkout.session.completed" }),
});
expect(response.status).toBe(400); // or your rejection code (401, 403)
});Test: Accept requests with a valid signature
Use buildRequest with the correct secret and confirm your handler returns 200:
it("accepts requests with a valid signature", async () => {
const signed = await client.buildRequest(HANDLER, {
provider: "stripe",
template: "checkout.session.completed",
secret: process.env.STRIPE_WEBHOOK_SECRET!,
});
const response = await fetch(HANDLER, {
method: signed.method,
headers: signed.headers,
body: signed.body,
});
expect(response.status).toBe(200); // or 202, 204 — match your handler
});Test: Verify signatures across providers
Each provider uses different headers and encodings. Generate a signed payload for each and confirm your handler accepts them all:
const providers = [
{ provider: "stripe", template: "invoice.paid", secret: process.env.STRIPE_WEBHOOK_SECRET! },
{ provider: "github", template: "push", secret: process.env.GITHUB_SECRET! },
{ provider: "shopify", template: "orders/create", secret: process.env.SHOPIFY_SECRET! },
{ provider: "slack", template: "slash_command", secret: process.env.SLACK_SECRET! },
] as const;
for (const { provider, template, secret } of providers) {
it(`accepts a valid ${provider} signature`, async () => {
const handler = `http://localhost:3000/api/webhooks/${provider}`;
const signed = await client.buildRequest(handler, {
provider,
template,
secret,
});
const response = await fetch(handler, {
method: signed.method,
headers: signed.headers,
body: signed.body,
});
expect(response.status).toBe(200); // or 202, 204 — match your handler
});
}This catches the subtle differences between providers: Stripe uses a custom signature scheme, GitHub uses hex-encoded HMAC, Shopify uses base64-encoded HMAC, and Slack includes a timestamp prefix. A test that passes for Stripe but not Shopify means your base64 handling is wrong.
What the CVEs teach us
The Plunk and GitLab vulnerabilities weren't exotic attacks. They exploited missing input validation on webhook-related URLs — something a short validation function would have prevented.
Webhook endpoints are the rare case where your application is designed to accept requests from the public internet. Treat them with the same scrutiny you'd give a login endpoint: validate input, verify identity, reject anything unexpected, and test the rejection paths as carefully as the happy paths.
SDK Reference
buildRequest, signature verification, and provider templates — full API reference.