Skip to content
Back to blog
SecuritySecuritySSRFBest Practices

Webhook Security Checklist: SSRF, Replay Attacks, and Signature Bypass

A critical SSRF hit Plunk in March 2026. A high-severity SSRF hit GitLab in September 2025. Here's the checklist: verify signatures with constant-time comparison, reject stale timestamps, block private IPs, and test all three.

Apr 6, 20268 min read

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:

ProviderTimestamp locationReplay protection
Stripet= prefix in stripe-signature headerYes — Stripe's constructEvent rejects events older than 300 seconds
Slackx-slack-request-timestamp headerYes — but you must validate it yourself
Standard Webhookswebhook-timestamp headerYes — but you must validate it yourself
ShopifyX-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
GitHubNot includedNo — use idempotency instead
TwilioNot includedNo — 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 HTTP
  • http://metadata.google.internal/ — GCP metadata
  • http://192.168.1.1/admin — Internal network devices
  • http://[::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:

CheckProtects againstHow
Constant-time signature comparisonTiming side-channelcrypto.timingSafeEqual()
Timestamp validation (5-min window)Replay attacksCompare provider timestamp to Date.now()
Idempotency keys for providers without timestampsReplay attacksTrack processed event IDs in database
Private IP blocking on outbound URLsSSRFResolve hostname, check all addresses against blocked ranges
DNS pinning on outbound requestsDNS rebindingResolve once, reuse the IP for the request
Payload size limit (1 MB)Resource exhaustionCheck Content-Length before reading body
Rate limiting by source IPBrute-force / DoSToken bucket or sliding window
Non-HTTP scheme rejectionProtocol smugglingAllow 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.