Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 8 min read

Webhook Security in Production: HMAC-SHA256 Signatures, Timestamp Drift & Replay Protection

Accepting inbound webhooks from Stripe, GitHub, Slack, Shopify, or a partner ERP looks deceptively simple in a prototype: expose a POST /webhooks/provider endpoint, parse the JSON body, and update the database. In produc

Webhook Security in Production: HMAC-SHA256 Signatures, Timestamp Drift & Replay Protection

Accepting inbound webhooks from Stripe, GitHub, Slack, Shopify, or a partner ERP looks deceptively simple in a prototype: expose a POST /webhooks/provider endpoint, parse the JSON body, and update the database. In production, an unverified or naively verified webhook endpoint is an open backdoor allowing anyone who discovers the URL to forge payment_intent.succeeded events, replay captured deliveries, or trigger timing side-channel leaks.

While building the developer security tools on PixelValut Tools, I have debugged webhook signature failures across dozens of Node.js, Laravel, and Go codebases. Almost every bug comes down to one of four mistakes: computing the HMAC over re-serialized JSON instead of the raw request bytes, comparing digests with === instead of a constant-time equality check, ignoring the timestamp header so captured payloads remain replayable forever, or mixing up hex vs. Base64 digest encodings.

πŸ“š Series Roadmap β€” Browser-Side Security & Zero-Trust Forensics

  • Part 1: Deobfuscating Malicious npm & Browser Payloads: Base64, Hex, Unicode Escapes & Homoglyphs
  • Part 2 (This Post): Webhook Security in Production: HMAC-SHA256 Signatures, Timestamp Drift & Replay Protection
  • Part 3: OAuth 2.1, OIDC ID Tokens & PKCE Forensics: Auditing Claims, Nonces & Audience Confusion in SPAs
  • Part 4: Regular Expression Denial of Service (ReDoS) in JS & PHP: Spotting Catastrophic Backtracking
  • Part 5: Subdomain Takeover & Dangling DNS Reconnaissance: Auditing CNAME, NS, TXT & TLS Certificates
  • Part 6: Zero-Trust Reverse Proxy Hardening in Nginx: Rate Limiting, Request Smuggling Guards & mTLS

How do you securely verify webhook HMAC-SHA256 signatures and prevent replay attacks?

Direct answer: Read the exact raw HTTP request body bytes before any JSON middleware parses them, concatenate the provider's delivery timestamp with the raw body, compute an HMAC-SHA256 digest using your endpoint secret, compare digests with crypto.timingSafeEqual(), reject timestamps outside a 300-second window, and deduplicate event IDs in Redis or PostgreSQL.

Server rack with network cables representing signed webhook traffic

Why webhook signature checks fail in real codebases (The raw-body trap)

The single most common webhook bug happens when an Express, Next.js, or middleware pipeline parses req.body into a JavaScript object, and the developer then runs JSON.stringify(req.body) to feed the HMAC verifier.

Cryptographic hash functions operate on exact byte sequences. If the webhook sender transmitted:

POST /webhooks/orders HTTP/1.1
X-Webhook-Timestamp: 1761588000
X-Webhook-Signature: sha256=8f4e2a91c03b7d...

{"event": "order.paid", "amount": 4900}

Notice the spaces after the colons. When JSON.stringify(req.body) re-serializes that object without spaces ({"event":"order.paid","amount":4900}), or changes key ordering or Unicode escaping, the resulting HMAC-SHA256 digest is completely different from the provider's signature.

You can test this exact byte sensitivity right now in your browser using the PixelValut HMAC Generator: paste {"event": "order.paid"} vs {"event":"order.paid"} with the same secret key and watch the SHA-256 hex output change completely.

Production Node.js / TypeScript implementation: Raw bytes + Timestamp + Constant-Time Compare + Replay Cache

Here is a complete, zero-dependency Node.js (crypto) verification function that enforces timestamp binding, constant-time byte comparison, and atomic replay deduplication:

import crypto from "node:crypto";

export interface WebhookVerifyOptions {
  rawBody: Buffer;          // Exact unparsed bytes from the HTTP socket
  signatureHeader: string;  // e.g., "t=1761588000,v1=5257a869e7ecebeda32..."
  secret: string;           // Endpoint signing secret (whsec_...)
  toleranceSeconds?: number;// Default: 300 seconds (5 minutes)
}

export function verifySignedWebhook({
  rawBody,
  signatureHeader,
  secret,
  toleranceSeconds = 300,
}: WebhookVerifyOptions): { valid: boolean; timestamp?: number; reason?: string } {
  // 1. Parse timestamp (t=) and v1 signatures from header
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((kv) => kv.trim().split("=", 2))
  );
  const timestamp = Number(parts.t);
  const receivedSigHex = parts.v1;
  if (!Number.isInteger(timestamp) || !receivedSigHex) {
    return { valid: false, reason: "malformed_signature_header" };
  }

  // 2. Enforce timestamp replay window (always compare in Unix seconds, not ms!)
  const nowSeconds = Math.floor(Date.now() / 1000);
  if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) {
    return { valid: false, timestamp, reason: "timestamp_outside_tolerance" };
  }

  // 3. Compute HMAC-SHA256 over "<timestamp>.<rawBody>"
  const signedPayload = Buffer.concat([
    Buffer.from(`${timestamp}.`, "utf8"),
    rawBody,
  ]);
  const expectedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest();

  // 4. Decode hex and guard length before crypto.timingSafeEqual
  const receivedBuf = Buffer.from(receivedSigHex, "hex");
  if (receivedBuf.length !== expectedSig.length) {
    return { valid: false, timestamp, reason: "signature_length_mismatch" };
  }
  const isMatch = crypto.timingSafeEqual(receivedBuf, expectedSig);
  return isMatch
    ? { valid: true, timestamp }
    : { valid: false, timestamp, reason: "signature_mismatch" };
}

Look closely at two critical lines in that implementation:

  • Seconds vs. milliseconds in timestamp checks (Math.floor(Date.now() / 1000)): Stripe, Slack, and most webhook specs send Unix timestamps in seconds (10 digits), whereas JavaScript Date.now() returns milliseconds (13 digits). Comparing them directly causes every request to fail the drift check or appear to come from 1970β€”a classic bug we covered in Why Does My Date Show 1970? and easy to inspect in the Unix Timestamp Converter.
  • Length guard before crypto.timingSafeEqual(): In Node.js, calling crypto.timingSafeEqual(a, b) when a.length !== b.length throws a synchronous RangeError (Input buffers must have the same byte length), turning a malformed header into an unhandled 500 Internal Server Error if not guarded.

A mechanical stopwatch resting beside a brass wax seal stamp on dark leather, representing cryptographic signature verification and timestamp replay windows

How Stripe, GitHub, Slack, and Shopify Format Webhook Signatures

Different providers sign slightly different canonical strings and encode the digest in either hexadecimal or Base64. Use this reference table when wiring or debugging a provider integration:

Provider Signature Header Name Canonical Signed String Format Digest Algorithm & Encoding Replay Protection Mechanism
Stripe Stripe-Signature <timestamp>.<raw_body> (from t= prefix) HMAC-SHA256 (Hex in v1=) 300s timestamp tolerance + evt_... unique ID deduplication
GitHub X-Hub-Signature-256 <raw_body> (body bytes only) HMAC-SHA256 (Hex prefixed with sha256=) Deduplicate X-GitHub-Delivery UUID header
Slack X-Slack-Signature v0:<X-Slack-Request-Timestamp>:<raw_body> HMAC-SHA256 (Hex prefixed with v0=) 300s check on X-Slack-Request-Timestamp
Shopify X-Shopify-Hmac-Sha256 <raw_body> (body bytes only) HMAC-SHA256 (Base64 encoded) Deduplicate X-Shopify-Webhook-Id header
Standard Webhooks / Svix webhook-signature <webhook-id>.<webhook-timestamp>.<raw_body> HMAC-SHA256 (Base64 prefixed with v1,) 300s check on webhook-timestamp + webhook-id

Notice how Shopify and Standard Webhooks encode the HMAC digest in Base64, while Stripe, GitHub, and Slack use lowercase hex. When testing custom webhook signatures locally, you can generate both hex and Base64 HMAC digests in the HMAC Generator and send test requests using the PixelValut API Tester.

Why a 5-minute timestamp window still needs an event-ID replay cache

Binding the timestamp inside the HMAC signature (<timestamp>.<raw_body>) stops an attacker from capturing a webhook today and replaying it next weekβ€”because changing the timestamp header invalidates the HMAC, and keeping the old timestamp trips the 300-second drift check.

However, within that 300-second window, a captured request (or a legitimate provider retry when your server times out) is still cryptographically valid. To prevent double-crediting an account or firing duplicate downstream actions, always combine HMAC verification with an atomic idempotency check:

-- Record the provider's unique event_id inside the same transaction as your state change
INSERT INTO processed_webhook_events (provider, event_id, received_at)
VALUES ('stripe', 'evt_3Q9xLm2eZvKYlo2C1abc9876', NOW())
ON CONFLICT (provider, event_id) DO NOTHING;

For the full downstream queue, dead-letter, and transactional outbox architecture once the webhook passes verification, see my DEV guides on Resilient ERP & CRM API Integrations: Retries, DLQs, Replay and Multi-Vendor Marketplace Payments: Ledgers & Payouts. And just as we saw in JSON Web Token (JWT) Security Pitfalls, never share the same HMAC secret across staging and production environments.

The 7-step production webhook security checklist

  1. Capture raw request bytes before body-parsing middleware. Use express.raw({ type: 'application/json' }) in Express or $request->getContent() in Laravelβ€”never JSON.stringify(req.body).
  2. Verify the canonical string against the provider spec. Check whether the provider prepends <timestamp>. or v0:<timestamp>: and whether the signature is hex or Base64 using the HMAC Generator.
  3. Use constant-time comparison (crypto.timingSafeEqual or hash_equals). Guard buffer lengths first so malformed headers return 401 Unauthorized instead of throwing a 500 exception.
  4. Enforce a strict Β±300s timestamp window. Convert timestamps in the Unix Timestamp Converter to confirm your server compares Unix seconds against Unix seconds (with NTP synchronized).
  5. Support dual-secret verification during key rotation. Allow your verifier to check [current_secret, previous_secret] so you can rotate webhook secrets with zero dropped deliveries.
  6. Deduplicate event_id atomically. Store each provider delivery ID in Redis (SET key 1 NX EX 86400) or a unique database constraint before executing business logic.
  7. Acknowledge fast (200 OK in <500ms) and process asynchronously. Verify the HMAC, persist the raw event to your database or queue, and return 200 OK immediately so slow downstream work never triggers retry storms.

Frequently asked questions

Why can't I just check the sender's IP address instead of verifying HMAC signatures?

Cloud webhook senders run on dynamic cloud egress pools (often AWS or GCP) shared across customers or subject to change without notice. Worse, if an attacker compromises another tenant on the same platform and points a webhook at your URL, the request arrives from the provider's genuine IP rangeβ€”only your endpoint-specific HMAC secret proves the event belongs to your account.

Why is === unsafe for comparing HMAC signature strings?

Standard string equality (=== or ==) exits on the first mismatched byte. While network jitter makes remote timing attacks over the internet noisy, constant-time comparison (crypto.timingSafeEqual in Node.js, hash_equals in PHP, hmac.compare_digest in Python) takes three lines of code and eliminates timing side-channels completely.

How do I rotate a compromised webhook secret without losing in-flight events?

Generate a second signing secret in the provider dashboard (Stripe and Svix support multiple active secrets per endpoint, sending multiple v1= values in the header). Update your verifier to accept a match against either the primary or secondary secret, deploy, and then revoke the old secret.

What HTTP status code should my webhook endpoint return when HMAC verification fails?

Return 401 Unauthorized or 400 Bad Request immediately and log the failure metadata (source IP, timestamp header, and missing/mismatched signature reason) without logging the secret itself.

Should I verify webhook signatures on localhost during development?

Yesβ€”keep signature verification active in every environment by setting a test signing secret in .env.testing and generating valid test signatures with the provider CLI (stripe listen) or the PixelValut HMAC Generator. Disabling verification in dev leads to if (env !== 'production') bypasses accidentally shipping to staging or production.

Follow & Connect

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.