Skip to content
Innovialabs

How to Verify Webhook Signatures with HMAC and Prevent Spoofing

Practice4 min

In short

Recompute the HMAC of the raw request body (and timestamp) with your shared secret, compare it to the signature in constant time, reject old timestamps and repeated event IDs, and only then trust and process the webhook.

The short answer

A webhook URL is public: anyone who finds it can send a fake "payment succeeded" request. A signature proves the request came from someone who knows the shared secret and that the body was not changed. To verify it:

  1. Read the raw body exactly as received, before JSON parsing.
  2. Compute HMAC-SHA256(secret, timestamp + "." + body) or whatever string the provider documents.
  3. Compare with the signature header using a constant-time comparison.
  4. Reject requests with an old timestamp and event IDs you already processed.
  5. Only then parse and act on the payload.

How HMAC signing works

HMAC is a hash function combined with a secret key. The sender and receiver share the secret; the sender computes the HMAC of the message and puts it in a header. The receiver recomputes it. Without the secret, an attacker cannot produce a valid signature, and any change to the body changes the result.

Note that HMAC is not encryption: the body is still readable, so webhooks must go over HTTPS.

Code example: Node.js

import crypto from "node:crypto";

export function verifyWebhook(rawBody, timestamp, signature, secret) {
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return false; // 5-minute window

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(String(signature), "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

In Express, read the body with express.raw({ type: "application/json" }) on the webhook route; re-serializing parsed JSON can change spacing or key order and break the signature.

Code example: Python

import hashlib
import hmac
import time

def verify_webhook(raw_body: bytes, timestamp: str, signature: str, secret: bytes) -> bool:
    if abs(time.time() - int(timestamp)) > 300:
        return False
    message = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret, message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Why constant-time comparison

A normal == stops at the first different character. By measuring response times, an attacker can in theory guess the signature byte by byte. crypto.timingSafeEqual and hmac.compare_digest take the same time regardless of where the strings differ.

Protecting against replay

A valid signed request can be captured and sent again. Two defenses work together:

  • Timestamp in the signed string: reject requests outside a short window. The timestamp must be part of the HMAC input, otherwise an attacker just changes it.
  • Idempotency by event ID: store processed event or transaction IDs and ignore repeats. Providers also retry webhooks legitimately, so this protects you from double crediting too.

IP allowlists

Some providers publish the addresses their callbacks come from. An allowlist is a useful extra layer, not a replacement for signatures:

  • IP lists change; keep them in config, not hardcoded.
  • Behind a proxy or load balancer, take the client IP only from a header your own proxy sets, otherwise it can be spoofed.

Secret rotation

  • Store the secret in a secrets manager or environment variables, never in the repository.
  • Support two active secrets during rotation: accept a signature valid under either, then remove the old one.
  • Rotate immediately if the secret may have leaked, for example after an employee with access leaves.
  • Use a separate secret per environment and per integration.

Payme and Click

Local payment providers do not always use classic HMAC, so follow their documentation exactly:

  • Payme Merchant API calls your endpoint with HTTP Basic authentication, where the password is the merchant key from the merchant cabinet. Check the Authorization header on every request with a constant-time comparison, and return the error the protocol defines if it does not match. Checking the source IP adds a second layer.
  • Click SHOP API sends a sign_string: an MD5 hash of request fields concatenated with your secret key. Rebuild the string in the documented field order, hash it and compare. The exact field set differs between the Prepare and Complete requests.

In both cases also verify the amount and order ID against your database and make processing idempotent: a correct signature does not mean the amount matches your order.

Common mistakes

  • Verifying the signature after parsing and re-serializing JSON.
  • Logging the full request with headers, including the signature or Basic credentials.
  • Returning detailed errors that tell an attacker which check failed.
  • Treating a webhook as proof of payment without checking amount and status.

FAQ

No. URLs leak through logs, proxies and screenshots. Treat the URL as public and rely on signatures or authentication.

We can help with this

If you'd rather get the result than figure it all out yourself — this is what we do every day.

Discuss a project

Read next

Have an idea or a task? Tell us about it

We'll reply within one business day and propose a solution. The consultation is free.