All WordPress HTML Templates Forms & Webhooks AI & Tools
Forms & Webhooks

Webhook Security: Signature Verification, Replay Attacks and Secrets

A practitioner’s guide to webhook security: verify webhook signature on raw bytes, stop replay attacks with timestamps and nonce caches, and rotate secrets

A real working moment illustrating the theme of an article about webhook security. Wide 16:9 banner, one strong focal point, magazine editorial quality, authentic and unstaged.

A client’s payment reconciliation job started double-crediting accounts. The cause wasn’t fraud. Their Express app parsed the JSON body with express.json() before the signature check ran, so verification compared an HMAC of a re-serialised payload against the sender’s HMAC of the original bytes. It failed every time. Someone “fixed” it six months earlier by wrapping the comparison in a try/catch and returning 200 regardless.

That endpoint was an open, unauthenticated write API to their billing ledger for half a year.

Webhook security is one of those areas where the code looks fine, the tests pass, and the hole stays open until someone finds it. The three things that actually matter: verifying signatures on the exact bytes you received, rejecting replays with a timestamp plus a nonce cache, and handling secrets so rotation doesn’t require a deploy window. Everything else is secondary.

Key Takeaways

  • Compute the HMAC over the raw request body, before any JSON parsing or middleware touches it. Re-serialised JSON produces a different byte string and a different digest, every time.
  • Use constant-time comparison (crypto.timingSafeEqual in Node, hash_equals in PHP) and check buffer lengths first, because timingSafeEqual throws on mismatched lengths.
  • A signature alone does not stop replay. You need a signed timestamp with a tolerance window (Stripe and Slack both default to 300 seconds) plus a short-lived store of seen message IDs.
  • Support two active secrets at once. Rotation without an overlap window means dropped deliveries, and dropped deliveries mean someone will disable verification to stop the pager.
  • Return 400 on a bad signature, not 200. Swallowing failures silently is how the hole above survived six months of code review.

The failure modes we actually see on client code

Across audits, the same handful of bugs keep showing up. None of them are exotic.

  • Parsed body, lost bytes. Body-parser middleware mounted globally, verification downstream. The digest can never match, so verification gets bypassed or made non-fatal.
  • String equality on the digest. if (sig === expected) leaks timing information. It’s a narrow attack in practice over the public internet, but it costs one function call to fix, so there’s no argument for leaving it.
  • No timestamp check. Signature valid forever. Capture one delivery from a log aggregator or a misconfigured proxy and you can resend it a thousand times.
  • Secret in the repo. Usually in a config.sample.php that someone filled in and committed in 2021.
  • Verification in the app, not at the edge of the handler. Queue the job first, verify later, and you’ve built a free denial-of-service amplifier.
  • Trusting the payload as truth. The payload tells you something happened. For money, permissions or deletions, re-fetch the object from the sender’s API before acting on it.
A real working moment that fits an article section about

How to verify a webhook signature correctly

Nearly every provider uses the same primitive: HMAC-SHA256 over a canonical string, keyed with a shared secret. What differs is the encoding and what goes into the signed string.

  • GitHub: X-Hub-Signature-256: sha256=<hex>, HMAC over the raw body only.
  • Shopify: X-Shopify-Hmac-SHA256, base64 encoded, raw body only.
  • Stripe: Stripe-Signature: t=1699999999,v1=<hex>, HMAC over t + "." + rawBody.
  • Slack: X-Slack-Signature: v0=<hex> over v0:{timestamp}:{body}, with the timestamp in X-Slack-Request-Timestamp.
  • Standard Webhooks (the spec behind Svix, now used by Clerk, Resend and others): webhook-id, webhook-timestamp, webhook-signature: v1,<base64>, signing {id}.{timestamp}.{payload}.

The ones that include a timestamp in the signed string are strictly better, because the timestamp can’t be stripped or rewritten without breaking the signature. If you’re designing your own, copy Standard Webhooks and move on.

Here’s a Stripe-style verifier in Node 20 or later, with the raw body preserved properly:

import express from 'express';
import crypto from 'node:crypto';

const app = express();

// express.raw gives you a Buffer. Mount it on this route only, and keep the
// limit tight: a 256kb cap means a junk 50MB POST dies before it reaches you.
app.post('/hooks/payments',
  express.raw({ type: 'application/json', limit: '256kb' }),
  (req, res) => {
    const header = req.get('Stripe-Signature') || '';
    const secrets = [process.env.WEBHOOKSECRET, process.env.WEBHOOKSECRET_PREV]
      .filter(Boolean);

    if (!verify(req.body, header, secrets)) {
      // 400, not 200. You want this in the sender's failure dashboard.
      return res.status(400).send('invalid signature');
    }

    const event = JSON.parse(req.body.toString('utf8'));
    res.sendStatus(202);           // acknowledge fast
    enqueue(event);                // do the real work off the request path
  });

function verify(rawBody, header, secrets, toleranceSec = 300) {
  const parts = {};
  const candidates = [];

  for (const kv of header.split(',')) {
    const i = kv.indexOf('=');
    if (i < 1) continue;
    const key = kv.slice(0, i).trim();
    const val = kv.slice(i + 1).trim();   // slice, not split: hex can contain '='padding in base64 schemes
    if (key === 'v1') candidates.push(val);
    else parts[key] = val;
  }

  const ts = Number(parts.t);
  if (!Number.isFinite(ts) || !candidates.length) return false;

  // Reject anything outside the window in both directions. Future-dated
  // deliveries mean someone's clock is wrong or someone's forging.
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > toleranceSec) return false;

  for (const secret of secrets) {
    const expected = crypto.createHmac('sha256', secret)
      .update(${parts.t}.)     // two updates avoids coercing the Buffer to a string
      .update(rawBody)
      .digest();

    for (const candidate of candidates) {
      const given = Buffer.from(candidate, 'hex');
      if (given.length === expected.length &&
          crypto.timingSafeEqual(given, expected)) return true;
    }
  }
  return false;
}

Two details people get wrong. timingSafeEqual throws a RangeError if the buffers differ in length, so the length guard isn’t optional. A provider may also send multiple v1 values during their own secret rotation, which is why you loop over all candidates.

The PHP and WordPress version

In PHP the raw body is php://input, and you can only read it once in some SAPI configurations, so grab it first. In a WordPress REST route, $request->get_body() gives you the untouched body, which is the safe path.

addaction( 'restapi_init', function () {
    registerrestroute( 'hooks/v1', '/payments', array(
        'methods'             => 'POST',
        'callback'            => 'scwhandlepayment_hook',
        // Verification belongs here. If it fails, the callback never runs.
        'permissioncallback' => 'scwverifyhooksignature',
    ) );
} );

function scwverifyhooksignature( WPREST_Request $request ) {
    $header = $request->getheader( 'xhubsignature256' ); // WP normalises header names
    $body   = $request->get_body();

    if ( ! $header || 0 !== strpos( $header, 'sha256=' ) ) {
        return new WPError( 'badsignature', 'Missing signature', array( 'status' => 400 ) );
    }

    // Two secrets active during a rotation overlap.
    foreach ( array( WEBHOOKSECRET, defined( 'WEBHOOKSECRETPREV' ) ? WEBHOOKSECRET_PREV : '' ) as $secret ) {
        if ( '' === $secret ) {
            continue;
        }
        $expected = 'sha256=' . hash_hmac( 'sha256', $body, $secret );
        if ( hash_equals( $expected, $header ) ) { // expected first: it is the trusted value
            return true;
        }
    }

    return new WPError( 'badsignature', 'Invalid signature', array( 'status' => 400 ) );
}

If you’re building these endpoints inside WordPress, the auth, caching and versioning decisions around them are worth getting right too; we covered that in custom REST API endpoints in WordPress. One WordPress-specific trap: a caching plugin or an object cache that serves a cached 200 for a POST will quietly break your handler. Exclude the route explicitly.

Replay attacks: the timestamp is half the answer

A valid signature proves the message came from someone holding the secret. It says nothing about when, or whether you’ve already processed it. Replay protection is two layers.

Layer one is the tolerance window. Reject anything whose signed timestamp is more than 300 seconds from now. Five minutes is the de facto standard because it absorbs normal clock drift and sender-side retry backoff without leaving a wide window. Go tighter than 60 seconds and you’ll start dropping legitimate deliveries from senders whose NTP is off. We’ve seen 40 seconds of skew on a self-hosted GitLab instance.

Layer two is a nonce cache. Inside the window, a captured request is still replayable. Store the message ID with a TTL comfortably longer than your tolerance:

// TTL = tolerance (300s) + clock skew margin + safety. 15 minutes is plenty.
const first = await redis.set(whk:${event.id}, '1', { NX: true, EX: 900 });
if (!first) {
  // Already seen. Acknowledge so the sender stops retrying, then stop.
  return res.sendStatus(200);
}

The nonce cache is a security control with a 15 minute memory. It is not deduplication. Providers retry for hours or days, so a legitimate retry 26 hours later will sail past the cache and must be made safe by your handler logic instead. That’s a separate problem with a separate solution, covered in detail in idempotency for webhooks. Build both. People routinely build one and assume it covers the other.

Secrets: rotation is the part everyone skips

Generate secrets with at least 256 bits of entropy (openssl rand -hex 32). Store them in your platform’s secret manager or environment, never in the repo, and never in a database row that your admin UI renders.

The real work is rotation. Single-secret systems can’t rotate without a gap, so nobody rotates, so a secret leaked into a CI log in 2023 is still live. Fix the design instead:

  1. Add WEBHOOKSECRETPREV and make your verifier accept either. Deploy that first.
  2. Move the current value into PREV, set a fresh WEBHOOKSECRET, deploy.
  3. Update the sender’s configuration to the new secret.
  4. Wait out the sender’s maximum retry window, usually 24 to 72 hours.
  5. Clear _PREV.

Log which secret matched, by index not by value. When step 5 arrives, you want evidence that nothing has used the old key for 48 hours rather than a guess. Scrub signature headers from your logs too: a captured Stripe-Signature plus body is a replayable request for the next five minutes.

Asymmetric signing, if you have the choice

Shared secrets mean the receiver can forge messages that look like the sender. For multi-tenant platforms or anything feeding an audit trail, Ed25519 signatures are the better shape: the sender holds the private key, you hold a public key, and a breach of your side forges nothing. Standard Webhooks covers this with its v1a scheme, and Discord has signed interactions this way for years. Adoption is still thin in 2026, but if you’re the one designing the webhook, there’s no good reason to ship symmetric-only.

What to add when signatures aren’t enough

IP allowlists are useful defence in depth and a liability as a primary control. Providers publish ranges and change them. Stripe maintains a published list, but pinning to it means an unannounced addition becomes a silent outage. Allowlist at the edge, keep verification in the app, and alert on rejections rather than failing closed on IP alone.

mTLS is the right answer for partner integrations where you control both ends. It’s overkill for consuming SaaS webhooks, and most SaaS senders won’t support it anyway.

Re-fetching is the control people underuse. For anything irreversible, treat the webhook as a notification rather than data: take the object ID from the payload, call the provider’s API, act on what the API returns. Costs you one round trip. Removes an entire class of forged-payload attack.

Body size and content type limits at the proxy. A 256kb cap and a strict application/json requirement kills most junk before your runtime allocates anything.

If you’re on a static or JAMstack build and don’t want to own any of this for simple form traffic, a hosted endpoint is the pragmatic call. WebForms takes submissions and fans them out to email, Slack, Zapier, Telegram or your own webhook, which means the signing and replay handling sits on one side of the wire instead of in code you maintain.

If you’re the one sending webhooks

Senders have a mirror-image set of problems, and SSRF is the big one. A customer registers http://169.254.169.254/latest/meta-data/ as their endpoint and your delivery worker becomes a credential exfiltration tool.

  • Resolve the destination hostname and reject private, loopback, link-local and metadata ranges. Re-check after DNS resolution, not just on the submitted string, to block DNS rebinding.
  • Require HTTPS. No exceptions for “staging”.
  • Do not follow redirects on delivery requests. A 302 to an internal address defeats every check above.
  • Run deliveries through a dedicated egress proxy with an allowlist, and give the worker no instance credentials.
  • Sign with a timestamp and a unique message ID, expose both, and document your exact signed-string format. Vague docs are why integrators disable verification.
  • Set a hard timeout (5 to 10 seconds) and retry with exponential backoff plus jitter. Publish the retry schedule so receivers can size their replay caches.

Make it testable, or it will rot

Verification code is the easiest thing in your stack to break accidentally, because a middleware reorder three files away can silence it. Assert on it. Four tests, minimum:

  1. Valid signature and fresh timestamp returns 2xx.
  2. Valid signature with a timestamp 400 seconds old returns 400.
  3. Body mutated by one byte, signature unchanged, returns 400.
  4. Same valid request sent twice processes the side effect exactly once.

Add a fifth that verifies a payload signed with WEBHOOKSECRETPREV is accepted, so rotation support doesn’t quietly disappear. Fixtures with pinned, pre-computed signatures are better than generating them in the test: a test that signs with the same helper it verifies with will happily pass against a broken canonical string. Tunnel setups, replay tooling and fixture capture are covered in testing webhooks locally.

Frequently Asked Questions

Is HMAC-SHA256 still the right choice in 2026?

Yes. HMAC-SHA256 has no practical weaknesses and is supported everywhere, so there’s no reason to move to SHA-512 for webhook signing. The meaningful upgrade isn’t the hash, it’s going asymmetric with Ed25519 so the receiver can’t forge messages. Switch hash functions only if a provider forces you to.

Can I skip signature verification if I check the source IP?

No. IP addresses are the weakest link in the chain: providers change ranges without warning, and any compromise or misconfiguration inside your network perimeter makes the check meaningless. Use allowlisting as an extra layer at the edge and keep HMAC verification in the handler.

Why does my signature check fail even though the secret is correct?

Almost always because something re-serialised the body before you hashed it. Global JSON body-parser middleware is the usual culprit, followed by a framework that trims whitespace or a proxy that re-encodes the payload. Log the raw byte length and the first 32 bytes alongside the computed digest; the mismatch is usually visible immediately.

What HTTP status should I return for an invalid signature?

400, or 401 if you prefer the auth semantics. Either way, do not return 200: senders treat 2xx as success and will stop retrying, which means a genuine integration break will look healthy on both dashboards. Reserve 5xx for your own failures so the sender’s retry logic kicks in.

How long should my replay window be?

300 seconds matches what Stripe and Slack use and absorbs ordinary clock drift. Pair it with a nonce cache whose TTL is 900 seconds, giving comfortable headroom above the tolerance. Shorter windows look safer but start rejecting legitimate traffic from senders with unsynchronised clocks.

Where to start

Open the webhook handler you shipped most recently and check three lines: whether the HMAC runs against the raw body, whether the comparison is constant-time, and whether an invalid signature returns a non-2xx status. If any of those is wrong, fix it today. The window you’re exposed in is measured in months, not minutes.

Then add the second secret and the nonce cache. Those two changes take under an hour, and they turn webhook security from a thing you hope is still working into something you can rotate, test and prove.

hmac webhook signature verification node js standard webhooks ed25519 asymmetric signing timingSafeEqual webhook signature comparison verify webhook signature raw body webhook replay attack prevention timestamp tolerance webhook secret rotation two secrets webhook security wordpress rest api webhook signature verification