Webhook Signature Verification: Secure Your API Endpoints

Published:

Think HTTPS alone keeps webhooks safe? Think again. Attackers can forge payloads or replay valid events, and without signature checks your endpoints are exposed. This post shows how HMAC-SHA256 (a keyed SHA-256 hash) signatures prove a request’s origin, why you must compute the digest on the raw request bytes, use timing-safe comparisons, and handle secrets and replay windows properly. Read on for practical, copy-paste code in Python, Node, and PHP plus the common gotchas you’ll hit in production so you can stop guessing and start rejecting bogus webhooks.

Core Mechanics of Verifying Webhook Signatures

f_px71RQQyGd90EQk-eJzQ

Webhook signature verification relies on HMAC (Hash-based Message Authentication Code) to confirm an incoming request actually came from who you think sent it and wasn’t messed with in transit. Here’s how it works: the provider computes an HMAC digest of the payload using a shared secret and a hashing algorithm (usually SHA-256), then sticks that digest in a header. Your server does the same calculation with the same secret and compares the results. Match? You’re good. No match? Reject it.

The shared secret is a random string both you and the webhook provider keep private. It’s basically a password that never leaves your server. The provider uses it to sign every webhook they send. You use it to verify each request. Don’t put this secret in your code. Load it from an environment variable or a secret manager.

Computing the HMAC digest means feeding the exact raw payload bytes (before any parsing or changes) through a cryptographic function with your secret. Most providers use HMAC-SHA256, which spits out a 256-bit hash. You then compare your computed hash to the one in the signature header using a timing-safe comparison function. Not a plain string check. Timing-safe matters because attackers can learn information from response-time variations.

import hmac
import hashlib

def verify_signature(payload_bytes, received_signature, secret):
    computed = hmac.new(secret.encode(), payload_bytes, hashlib.sha256).hexdigest()
    return hmac.compare_digest(computed, received_signature)

Requirements for correct verification:

  • Use the raw request body bytes exactly as received, before JSON parsing or middleware gets involved.
  • Pull the signature from the right HTTP header for your provider (names vary).
  • Always compare signatures using a timing-safe function like compare_digest, timingSafeEqual, or hash_equals.

Verifying Webhooks in Python

46YUf_E-Q4SrH3j_VpxgQA

Python’s built-in hmac library handles HMAC-SHA256 out of the box. Frameworks like Flask make grabbing the raw request body easy. Standard flow: read the unmodified body bytes, compute the digest using your secret, extract the signature from the incoming headers, compare the two with hmac.compare_digest() to avoid timing leaks.

Here’s a minimal Flask example verifying a webhook signature sent in an X-Signature-SHA256 header:

from flask import Flask, request
import hmac
import hashlib

app = Flask(__name__)
SECRET = "your-webhook-secret"

@app.route("/webhook", methods=["POST"])
def handle_webhook():
    raw_body = request.get_data()
    received_sig = request.headers.get("X-Signature-SHA256", "")

    computed_sig = hmac.new(
        SECRET.encode(),
        raw_body,
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(computed_sig, received_sig):
        return "Unauthorized", 401

    # Process webhook payload
    return "OK", 200

Verification flow:

  1. Grab the raw request body using request.get_data() before Flask parses it as JSON.
  2. Compute the HMAC-SHA256 digest by calling hmac.new(secret, body, hashlib.sha256).hexdigest().
  3. Pull the signature from the expected header (like X-Signature-SHA256).
  4. Compare the computed digest to the received signature using hmac.compare_digest(), which runs in constant time and blocks timing attacks.

Verifying Webhooks in Node.js

h-s58z26T1mbZ4KOXwdDBA

Node’s built-in crypto module gives you createHmac for SHA256 signatures. With Express, you need to capture the raw request body before body-parser middleware converts it to JSON. Signature verification has to happen on the exact bytes the sender signed.

const express = require("express");
const crypto = require("crypto");

const app = express();
const SECRET = "your-webhook-secret";

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

app.post("/webhook", (req, res) => {
  const receivedSig = req.headers["x-signature-sha256"] || "";
  const computedSig = crypto
    .createHmac("sha256", SECRET)
    .update(req.rawBody)
    .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(computedSig), Buffer.from(receivedSig))) {
    return res.status(401).send("Unauthorized");
  }

  // Process webhook
  res.status(200).send("OK");
});

app.listen(8080);

Implementation points:

  • Use the verify callback in express.json() to capture the raw buffer before parsing.
  • Extract the signature header (header names are case-insensitive in Express but usually lowercase).
  • Compare the computed and received signatures using crypto.timingSafeEqual() after converting both to Buffers of equal length.

Verifying Webhooks in PHP

d5qU1eONTvyCZpyaxLPhPQ

PHP’s hash_hmac function produces HMAC-SHA256 digests directly. Reading the raw request body from php://input ensures you’re working with the exact bytes the sender signed. Avoids issues from parsing middleware or superglobal transformations.

<?php
$secret = "your-webhook-secret";
$rawBody = file_get_contents("php://input");
$receivedSig = $_SERVER["HTTP_X_SIGNATURE_SHA256"] ?? "";

$computedSig = hash_hmac("sha256", $rawBody, $secret);

if (!hash_equals($computedSig, $receivedSig)) {
    http_response_code(401);
    exit("Unauthorized");
}

// Process webhook payload
http_response_code(200);
echo "OK";

PHP’s hash_equals does a timing-safe comparison and handles different string lengths gracefully, which prevents type-juggling bugs. Always use it instead of == or === when comparing cryptographic hashes or signatures.

Understanding HMAC and SHA-256

I9HswhIaS2KXdscqvV5eNA

HMAC (Hash-based Message Authentication Code) combines a secret key with a cryptographic hash function to produce a digest that proves both authenticity and integrity. Sender and receiver share a secret but never transmit it. Each computes the same hash locally. If an attacker doesn’t know the secret, they can’t forge a valid signature even if they capture thousands of legitimate payloads.

SHA-256 is the most popular hashing algorithm for webhooks because it’s fast, produces a fixed 256-bit output, and is collision-resistant (finding two different inputs with the same hash is computationally infeasible). Most webhook providers standardized on HMAC-SHA256 years ago. It’s still the industry default in 2026. Older functions like MD5 and SHA-1 are deprecated and vulnerable to collision attacks, so always reject them if a provider tries to downgrade.

HMAC-SHA256’s strength depends on three factors: the hash function itself (SHA-256 is solid), the length of the hash output (256 bits is plenty for webhooks), and the quality of your secret key. Use a high-entropy secret that’s at least 32 bytes (256 bits) long, generated by a cryptographically secure random number generator. A weak or short secret turns strong crypto into theater.

Safe Handling of Webhook Secrets

krWh7jrEQY2ai2Y2s7sc4w

Secrets should never be hard-coded in your application source. Load them from environment variables, a secret manager (AWS Secrets Manager, Google Secret Manager, HashiCorp Vault), or a config file that’s excluded from version control. If your secret leaks into a public repository, assume it’s compromised and rotate immediately.

Rotate webhook secrets regularly. Every 90 days is a reasonable baseline, but increase frequency if your threat model demands it or if you suspect exposure. When rotating, coordinate with the webhook provider so both sides switch to the new secret at the same time, or set up a grace period where your server temporarily accepts signatures from both the old and new secret to avoid downtime.

Safe secret storage:

  • Store secrets in environment variables (process.env.WEBHOOK_SECRET) or a dedicated secret management service.
  • Never commit secrets to version control. Use .gitignore or .env files that stay local.
  • Rotate secrets every 90 days or immediately after any suspected compromise, and make sure the provider updates their configuration at the same time.

Preventing Replay Attacks

yk5eczivSIC6F-eJsrQjDg

A replay attack happens when an attacker captures a legitimate signed webhook and resends it later. Because the signature is valid, your server will accept the payload even though it’s stale or has already been processed. To block replays, many webhook providers include a timestamp in the payload or signature header. You reject any request older than a short tolerance window.

Check the timestamp before verifying the signature. If the timestamp is more than five minutes old (or outside your chosen window), reject the request immediately. This prevents an attacker from reusing a captured webhook hours or days later. Some providers also include a unique nonce or request ID that you can store in a short-lived cache (Redis, Memcached) to make sure you never process the same event twice.

Validating a timestamp window:

  1. Extract the timestamp from the payload or a dedicated header (like X-Webhook-Timestamp).
  2. Parse the timestamp into a Unix epoch integer or ISO 8601 date.
  3. Compute the difference between the current server time and the webhook timestamp.
  4. Reject the request if the difference exceeds your tolerance (typically ±5 minutes or 300 seconds) or if the timestamp is in the future, indicating clock skew or tampering.

Implementing Timing-Safe Comparisons

rtc3nfB2S4m-K_YXijEIwg

Standard string equality operators (==, ===) can leak information through timing differences. If your code compares two strings character by character and returns early on the first mismatch, an attacker can measure response times to learn the correct signature one byte at a time. This is called a timing attack, and it’s surprisingly practical over a fast network.

Timing-safe comparison functions compare every byte in constant time, no matter where differences occur. Python’s hmac.compare_digest, Node’s crypto.timingSafeEqual, and PHP’s hash_equals all ensure that verifying a correct signature takes the same amount of time as verifying an incorrect one. Eliminates the timing side channel.

import hmac

# Safe
if hmac.compare_digest(computed, received):
    pass

# Unsafe, leaks timing information
if computed == received:
    pass

Always use the language’s built-in constant-time compare when checking signatures, tokens, or any other secret value.

Platform-Specific Verification: Stripe

xqOUjAZMTJyelhl9NHr9mA

Stripe signs every webhook event with an endpoint-specific signing secret and includes a timestamp to prevent replays. The signature arrives in the Stripe-Signature header, which contains multiple key-value pairs: a timestamp (t=) and one or more versioned signatures (v1=). You build a signed payload by concatenating the timestamp, a period, and the raw request body, then compute the HMAC-SHA256 digest of that string.

Stripe’s official SDKs handle this automatically, but if you’re verifying manually, extract the timestamp and the v1 signature from the header, construct the signed payload as {timestamp}.{raw_body}, compute the HMAC, and compare. Reject any event where the timestamp is older than your tolerance window (Stripe recommends five minutes).

Stripe verification steps:

  1. Parse the Stripe-Signature header to extract the t (timestamp) and v1 (signature) values.
  2. Construct the signed payload string by concatenating the timestamp, a literal period character, and the raw request body: signed_payload = f"{timestamp}.{raw_body}".
  3. Compute the HMAC-SHA256 digest of the signed payload using your endpoint secret, compare it to the v1 signature using a timing-safe function, and check that the timestamp is within your allowed window.

Platform-Specific Verification: GitHub

fpVeU94xRgOqLwuPxjXj4g

GitHub uses HMAC-SHA256 and sends the signature in the X-Hub-Signature-256 header, prefixed with the string sha256=. The signature is a hex-encoded digest of the raw request body signed with your webhook secret. Older webhooks may also include X-Hub-Signature (SHA-1), but you should ignore that header and only verify X-Hub-Signature-256 to avoid downgrade attacks.

To verify, read the raw request body exactly as received, compute the HMAC-SHA256 digest with your secret, prepend sha256= to the hex digest, and compare it to the header value using a constant-time comparison.

import hmac
import hashlib

raw_body = request.get_data()
secret = "your-github-secret"
received_sig = request.headers.get("X-Hub-Signature-256", "")

computed_sig = "sha256=" + hmac.new(
    secret.encode(),
    raw_body,
    hashlib.sha256
).hexdigest()

if not hmac.compare_digest(computed_sig, received_sig):
    return "Unauthorized", 401

GitHub’s webhook settings let you choose which events to subscribe to and enforce SSL verification, which you should always enable to protect the payload in transit.

Platform-Specific Verification: Shopify

Shopify signs webhook payloads using HMAC-SHA256 but encodes the digest in base64 instead of hex. The signature appears in the X-Shopify-Hmac-Sha256 header. This encoding difference is a common source of verification failures. If you compute a hex digest and compare it to a base64-encoded header, the match will always fail.

Compute the HMAC-SHA256 of the raw body, encode the resulting digest in base64, and compare it to the header value. Use a timing-safe comparison to prevent leaks.

import hmac
import hashlib
import base64

raw_body = request.get_data()
secret = "your-shopify-secret"
received_sig = request.headers.get("X-Shopify-Hmac-Sha256", "")

computed_digest = hmac.new(
    secret.encode(),
    raw_body,
    hashlib.sha256
).digest()
computed_sig = base64.b64encode(computed_digest).decode()

if not hmac.compare_digest(computed_sig, received_sig):
    return "Unauthorized", 401

Shopify also includes an X-Shopify-Shop-Domain header that tells you which store sent the webhook, which is useful if you serve multiple merchants.

Troubleshooting Signature Mismatches

When verification fails, start by logging both the computed and received signatures side by side (but never log the secret itself). If they’re different lengths or encodings, that’s your first clue. Check whether one is hex and the other is base64, or whether you’re missing a prefix like sha256=.

Make sure you’re using the exact raw request body. If your framework automatically parses JSON or applies middleware, it may modify whitespace, escape sequences, or Unicode characters. Capture the body before any transformation. Use request.get_data() in Flask, a verify callback in Express, or php://input in PHP. Even a single added newline or a reordered JSON key will break the signature.

Confirm you’re using the correct secret. If the provider recently rotated secrets or if you have multiple webhooks configured with different secrets, double-check which one applies. Also verify the hashing algorithm. Some legacy systems still use SHA-1, and comparing a SHA-256 digest to a SHA-1 header will never match.

Common root causes of signature mismatch:

  • Framework or middleware altered the request body (parsed JSON, normalized whitespace, changed encoding).
  • Encoding mismatch: computed signature is hex but header is base64, or vice versa.
  • Wrong secret: using an old secret, a different environment’s secret, or a typo.
  • Missing or incorrect signature prefix (GitHub requires sha256= at the start).
  • Clock skew or timestamp validation rejected the request before signature comparison, or timestamp wasn’t included in the signed payload when required.

Raw Payload Examples for Testing

Testing webhook verification requires using the exact unmodified request body. Even trivial differences like trailing newlines or different JSON key orders will produce a different HMAC digest and fail verification. Save the raw body from a real webhook or construct a known test payload and compute the expected signature offline.

Here’s a minimal JSON payload for testing:

{"event":"user.created","user_id":12345,"timestamp":1672531200}

And a slightly larger example with nested objects:

{
  "event": "order.completed",
  "order_id": "abc123",
  "amount": 99.99,
  "currency": "USD",
  "customer": {
    "id": 456,
    "email": "customer@example.com"
  },
  "timestamp": 1672531200
}

When you compute the HMAC-SHA256 of the first payload using the secret test-secret, you should get a consistent hex digest every time. Use that digest to verify your implementation is reading and hashing the body correctly.

Final Words

You now have the core mechanics: compute an HMAC (usually SHA‑256) from the raw request bytes, grab the provider’s signature header, and compare using a timing‑safe method.

We walked through concrete examples in Python, Node, and PHP, plus platform specifics for Stripe, GitHub, and Shopify, and covered secrets, timestamps, and common debugging steps.

Keep webhook signature verification tight: use raw payloads, secure and rotate secrets, validate timestamps, and run quick raw‑body tests. Do that and you’ll cut false positives and sleep better.

FAQ

Q: What is webhook signature verification?

A: Webhook signature verification is confirming a webhook came from the provider by computing an HMAC (typically SHA‑256) over the raw request body with your shared secret and comparing it to the signature header using a timing‑safe check.

Q: How to validate a webhook?

A: To validate a webhook and get your signature verified, register the endpoint secret with the provider, compute the HMAC of the exact raw payload using that secret, extract the provider signature header, then compare using a timing‑safe check.

curtisharmon
Curtis has spent over two decades guiding hunters and anglers through the backcountry of Montana and Wyoming. His expertise in elk hunting and fly fishing has made him a sought-after voice in the outdoor community. Curtis combines traditional woodsmanship with modern techniques to help readers succeed in the field.

Related articles

Recent articles