Product
Send Receive
Back to blog

Securing Webhooks with HMAC Signatures

webhooksengineeringsecurity

Your webhook endpoint is a publicly accessible URL. Without some form of verification, anyone who discovers it can send fake events to your application and your application will happily process them.

HMAC signatures solve this problem. They let the receiver verify two things: that the webhook came from the expected sender, and that the payload hasn’t been tampered with in transit. Let’s walk through how they work and how to implement them on both sides.

Why You Need Webhook Signatures

Consider what happens without them. An attacker who finds your webhook URL (through source code, network traffic, or just guessing common paths like /webhooks) could:

  • Send a fake payment.completed event to mark an unpaid order as fulfilled
  • Trigger business logic with fabricated data
  • Overwhelm your system with junk events

Checking the source IP isn’t reliable as IPs can be spoofed, and webhook senders often use dynamic IP ranges. HTTPS encrypts the payload in transit but doesn’t prove who sent it. You need a mechanism that’s tied to a shared secret between you and the sender.

How HMAC Works

HMAC (Hash-based Message Authentication Code) is a specific type of cryptographic hash that uses a secret key. Here’s the basic idea:

  1. The sender and receiver share a secret key (established when the webhook is configured).
  2. When sending a webhook, the sender computes an HMAC of the request body using the shared secret.
  3. The sender includes the resulting hash in a request header.
  4. The receiver computes the same HMAC using the same secret and the received body.
  5. If the two hashes match, the request is authentic and unmodified.

The critical property: you can’t compute a valid HMAC without knowing the secret key. So even if an attacker can see the webhook payload and the signature, they can’t forge a new one without the key.

The Signing Process (Sender Side)

Here’s how to sign a webhook before sending it:

Node.js

const crypto = require("crypto");

function signWebhook(payload, secret) {
  const body = JSON.stringify(payload);
  const signature = crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("hex");

  return `sha256=${signature}`;
}

// When sending the webhook
const payload = {
  event: "payment.completed",
  timestamp: new Date().toISOString(),
  data: { payment_id: "pay_8xk2m9", amount: 4999 },
};

const signature = signWebhook(payload, customerWebhookSecret);

await fetch(customerWebhookUrl, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Webhook-Signature": signature,
  },
  body: JSON.stringify(payload),
});

Python

import hmac
import hashlib
import json

def sign_webhook(payload: dict, secret: str) -> str:
    body = json.dumps(payload, separators=(",", ":"))
    signature = hmac.new(
        secret.encode(),
        body.encode(),
        hashlib.sha256
    ).hexdigest()
    return f"sha256={signature}"

Go

func signWebhook(payload []byte, secret string) string {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(payload)
    return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}

A few important details:

  • Sign the raw body bytes, not a re-serialized version of the parsed JSON. JSON serialization isn’t deterministic — different libraries may order keys differently or handle whitespace differently. The receiver needs to verify against the exact bytes that were sent.
  • Use SHA-256. SHA-1 is considered weak. MD5 is broken. SHA-256 is the standard choice for HMAC signatures today.
  • Prefix the signature with the algorithm (e.g., sha256=) so the receiver knows how to verify it.

The Verification Process (Receiver Side)

Verification is the mirror of signing. The receiver computes the expected signature and compares it to the one in the request header.

Node.js (Express)

const crypto = require("crypto");

function verifyWebhook(req, secret) {
  const signature = req.headers["x-webhook-signature"];
  if (!signature) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(req.rawBody) // Must use the raw body, not parsed JSON
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(`sha256=${expected}`)
  );
}

app.post("/webhooks", (req, res) => {
  if (!verifyWebhook(req, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send("Invalid signature");
  }

  // Process the verified event
  res.status(200).send("OK");
});

Python (Flask)

import hmac
import hashlib

def verify_webhook(payload_bytes: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        payload_bytes,
        hashlib.sha256
    ).hexdigest()
    expected_sig = f"sha256={expected}"
    return hmac.compare_digest(signature, expected_sig)

@app.route("/webhooks", methods=["POST"])
def handle_webhook():
    signature = request.headers.get("X-Webhook-Signature")
    if not signature:
        return "Missing signature", 401

    if not verify_webhook(request.data, signature, WEBHOOK_SECRET):
        return "Invalid signature", 401

    # Process the verified event
    return "OK", 200

Go

func verifyWebhook(body []byte, signature, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(signature), []byte(expected))
}

Critical Implementation Details

Use Timing-Safe Comparison

Never compare signatures with ==. Standard string comparison returns as soon as it finds a mismatched character which means the comparison time reveals information about how many characters matched. An attacker can exploit this to guess the signature one character at a time.

Use the constant-time comparison functions provided by your language:

  • Node.js: crypto.timingSafeEqual()
  • Python: hmac.compare_digest()
  • Go: hmac.Equal()
  • Ruby: Rack::Utils.secure_compare()

Verify Against the Raw Body

This is the most common mistake in webhook verification. If your framework parses the JSON body before you can access the raw bytes, the re-serialized version may differ from what was originally sent. A single extra space or reordered key will produce a completely different HMAC.

In Express, you’ll need middleware to capture the raw body:

app.use(
  express.json({
    verify: (req, res, buf) => {
      req.rawBody = buf;
    },
  })
);

In Flask, use request.data (the raw bytes) rather than request.get_json() for verification.

Use Per-Customer Secrets

If you’re sending webhooks to multiple customers, each customer should have their own signing secret. A shared secret means any customer who knows the secret could forge webhooks that appear to come from your service to another customer’s endpoint.

Protecting Against Replay Attacks

A valid HMAC signature proves authenticity, but it doesn’t prevent replay attacks. If an attacker intercepts a signed webhook, they can resend it to the endpoint later — the signature is still valid because the payload hasn’t changed.

To mitigate this, include a timestamp in the signed payload and verify it on the receiving end:

function verifyWebhookWithTimestamp(req, secret, toleranceSeconds = 300) {
  const signature = req.headers["x-webhook-signature"];
  const timestamp = req.headers["x-webhook-timestamp"];

  if (!signature || !timestamp) return false;

  // Check if the timestamp is within tolerance
  const webhookTime = parseInt(timestamp, 10);
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - webhookTime) > toleranceSeconds) return false;

  // Include the timestamp in the signed content
  const signedContent = `${timestamp}.${req.rawBody}`;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signedContent)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(`sha256=${expected}`)
  );
}

The timestamp is included in the signed content so an attacker can’t modify it without invalidating the signature. A tolerance window of 5 minutes is typical — long enough to account for clock skew and network delays, short enough to limit the replay window.

Key Rotation

Secrets should be rotatable without downtime. The standard approach:

  1. Generate a new secret and share it with the consumer.
  2. For a transition period, sign webhooks with both the old and new secrets and send both signatures.
  3. The consumer verifies against both and accepts if either matches.
  4. Once the consumer confirms they’ve updated to the new secret, stop signing with the old one.

This is more operational complexity than most teams anticipate when they first implement webhook signing.

What Hookbridge Handles for You

If you’re sending webhooks through Hookbridge, all of this is handled automatically:

  • Every webhook is signed with SHA-256 HMAC
  • Each destination gets its own signing secret
  • Timestamps are included to prevent replay attacks
  • Key rotation is supported without delivery interruption

You get secure webhook delivery without building or maintaining the signing infrastructure yourself.

Next up: Designing a Retry Strategy That Won’t Take Down Your Consumers — where we’ll dig into exponential backoff, jitter, and the art of retrying without causing more problems than you solve.