Skip to content
Guide Security

How to Verify Webhook Signatures in Go, Node.js and Python

Verify webhook signatures correctly with HMAC: the raw-body requirement, timestamped signatures and replay windows, constant-time comparison, secret rotation, and working code in Go, Node.js and Python.

Published 10 min read
On this page

A webhook endpoint is a public URL that accepts a POST from anyone who finds it. Nothing about the request proves it came from your payment provider rather than a script that guessed the path. Signature verification is how you tell a genuine event from a forged one before you act on it — activate a subscription, ship an order, credit an account. Get it wrong and you either reject real events or trust fake ones. This guide covers the mechanics that matter, the one mistake that causes most failures, and working code in three languages.

Why signatures matter

Assume your endpoint URL is not secret. It sits in provider dashboards, proxy logs, and browser history; it leaks. An attacker who knows it can send a crafted invoice.payment_succeeded and, if you process unverified bodies, mark an unpaid order as paid. A signature closes that hole: the provider computes a keyed hash of the request body using a secret only the two of you share, and sends the hash in a header. You recompute it with the same secret. If they match, the body is authentic and unmodified. If they do not, you reject with 401 and stop.

Signatures are authentication and integrity, not encryption. The body is still plain text on the wire — use HTTPS for confidentiality. The signature only tells you that whoever sent this body holds the shared secret and that no byte changed in transit.

How HMAC signing works

Almost every provider uses HMAC — a hash (usually SHA-256) keyed with the shared secret. The provider does roughly this:

signature = HMAC_SHA256(secret, request_body)   # sent as hex or base64 in a header

You repeat the calculation on the bytes you received and compare. Because the secret is an input to the hash, an attacker without it cannot produce a matching signature for their own body, and cannot alter a real body without invalidating the signature. The whole scheme rests on two things being exactly right: the secret, and the bytes you hash.

Rule one: hash the raw request body

This is the single most common cause of signature failures, so it comes first. You must compute the HMAC over the exact bytes the provider signed — the raw request body, untouched. The moment a framework parses JSON and your code re-serialises it, the bytes change: key order shifts, whitespace collapses, 1.0 becomes 1, unicode escapes normalise. The re-serialised body is equivalent JSON but different bytes, so its HMAC differs and every verification fails.

The practical consequence: capture the raw body before any JSON middleware runs, verify against those raw bytes, and only parse afterwards. Every example below reads the raw body first and parses second.

Working code

These are a generic HMAC-SHA256 pattern. Providers differ in header name, encoding, and whether they prefix the value or fold in a timestamp — adapt the header lookup and format to your provider using the table further down. The verification core is the same everywhere.

Go

crypto/hmac ships hmac.Equal, which compares in constant time. Read the raw body with io.ReadAll and cap it.

package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
)

// verify checks an HMAC-SHA256 signature sent as a hex string in a header.
// Adapt the header name and encoding to your provider.
func verify(secret []byte, next func([]byte)) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MiB cap
		if err != nil {
			http.Error(w, "cannot read body", http.StatusBadRequest)
			return
		}

		mac := hmac.New(sha256.New, secret)
		mac.Write(body)
		expected := mac.Sum(nil)

		got, err := hex.DecodeString(r.Header.Get("X-Signature"))
		if err != nil || !hmac.Equal(got, expected) {
			http.Error(w, "invalid signature", http.StatusUnauthorized)
			return
		}

		next(body) // body is the verified raw bytes; parse it inside next.
		w.WriteHeader(http.StatusOK)
	}
}

Node.js and TypeScript

Use express.raw (or your framework’s raw-body option) so req.body is a Buffer of the original bytes, not parsed JSON. crypto.timingSafeEqual is the constant-time comparison, and it throws on length mismatch — so guard the length first.

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

const app = express();

// Raw body for this route only; a JSON parser must not run before it.
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
	const secret = process.env.WEBHOOK_SECRET!;
	const signature = req.header('X-Signature') ?? '';

	const expected = crypto
		.createHmac('sha256', secret)
		.update(req.body) // req.body is a Buffer of the raw bytes
		.digest('hex');

	const a = Buffer.from(signature);
	const b = Buffer.from(expected);
	if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
		return res.status(401).send('invalid signature');
	}

	const event = JSON.parse(req.body.toString('utf8')); // safe to parse now
	// ... process event
	res.sendStatus(200);
});

Python

hmac.compare_digest is the constant-time comparison. In Flask, request.get_data() returns the raw bytes before any JSON parsing.

import hashlib
import hmac
import os

from flask import Flask, request, abort

app = Flask(__name__)


@app.post("/webhooks")
def webhooks():
    secret = os.environ["WEBHOOK_SECRET"].encode()
    raw = request.get_data()  # raw bytes, before any JSON parsing

    expected = hmac.new(secret, raw, hashlib.sha256).hexdigest()
    signature = request.headers.get("X-Signature", "")

    if not hmac.compare_digest(expected, signature):
        abort(401)

    event = request.get_json()  # safe to parse now
    # ... process event
    return "", 200

Timestamped signatures and replay attacks

A plain body signature has a gap: it stays valid forever. If an attacker captures one real signed request, they can resend the identical bytes tomorrow and it verifies — because it is a genuine, correctly signed message. That is a replay attack.

Providers close it by signing a timestamp together with the body and sending the timestamp alongside the signature. You verify the signature over timestamp.body, then reject anything whose timestamp is outside a tolerance window — commonly around five minutes. A captured request replayed later fails the freshness check even though its signature is valid.

import time

TOLERANCE_SECONDS = 300  # 5 minutes


def verify_timestamped(secret: bytes, raw: bytes, timestamp: str, signature: str) -> bool:
    # Reject stale messages so a captured request cannot be replayed later.
    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False
    signed = timestamp.encode() + b"." + raw
    expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

The tolerance is a trade-off: too tight and legitimate deliveries fail when clocks drift or the provider queues a retry; too loose and the replay window widens. Keep your server clock synced with NTP — clock skew is a frequent cause of “signature valid yesterday, invalid today” reports. A freshness check reduces replays but is not a substitute for idempotency: the provider’s own retries deliver the same event legitimately, so your handler must tolerate duplicates regardless.

Constant-time comparison, and why == leaks timing

Never compare signatures with ==, strcmp, or an ordinary string equality check. Most equality routines short-circuit — they return the instant they hit a differing byte. So a signature that matches the first ten bytes takes measurably longer to reject than one that differs at byte one. An attacker who can measure your response time can exploit that difference to recover a valid signature byte by byte, without ever knowing the secret.

Constant-time comparison always examines every byte, so timing reveals nothing about how much matched. That is what hmac.Equal (Go), crypto.timingSafeEqual (Node) and hmac.compare_digest (Python) give you, and why every example above uses them. It is a one-line difference in the code and the entire point of the exercise — use them.

Provider schemes differ — do not assume one format

There is no universal webhook signature format. Header names, encodings, and what exactly gets signed vary per provider. Treat any single provider’s scheme as one example, not the standard, and read the provider’s own documentation before you write the header parsing.

ProviderHeaderWhat is signedEncoding
StripeStripe-Signaturet=<timestamp>,v1=<sig> over timestamp.bodyhex
GitHubX-Hub-Signature-256body, value prefixed sha256=hex
ShopifyX-Shopify-Hmac-Sha256bodybase64
SlackX-Slack-Signaturev0:<timestamp>:<body>, value prefixed v0=hex

The differences are exactly the things that break naive code: GitHub prefixes the value with sha256= (compare against the whole prefixed string, or strip it consistently); Shopify encodes base64 while the others use hex; Stripe and Slack fold a timestamp into the signed string, so hashing the bare body will never match. When in doubt, consult the provider’s documentation — for example Stripe’s webhook signing guide or GitHub’s webhook validation docs — and check your work against captured requests. See the Stripe and GitHub integration notes for the specifics HookWatch uses.

Rotating a signing secret

Secrets leak — into logs, a compromised laptop, an old employee’s password manager. You should be able to rotate one without a flag day. The mechanism is an overlap window: generate a new secret, then for a period accept a request if it verifies against either the old or the new secret. Once every sender is emitting the new signature, retire the old one.

def verify_during_rotation(raw: bytes, signature: str, secrets: list[bytes]) -> bool:
    # Accept if any active secret verifies. Try newest first.
    for secret in secrets:
        expected = hmac.new(secret, raw, hashlib.sha256).hexdigest()
        if hmac.compare_digest(expected, signature):
            return True
    return False

The window has to be long enough to cover in-flight retries and any caching on the sender’s side, and short enough that a leaked old secret is not accepted indefinitely. HookWatch signing-secret rotation uses a 24-hour overlap: after you rotate, both the old and new secrets are accepted for 24 hours, then the old one stops working.

Common verification mistakes

Most signature bugs are one of these:

  • Hashing a parsed body instead of the raw bytes. The number-one cause; covered above.
  • The wrong secret. A test-mode secret checked against live events, or the reverse. They are different keys and will never match.
  • Encoding mismatch. The provider sends base64 but you decode as hex, or you compare hex text to a raw digest. Match the provider’s encoding exactly.
  • The header prefix. Forgetting that a value is sha256=... or v0=... and comparing your bare hash to the prefixed string.
  • A trailing newline added to the secret when it was pasted into an environment variable or read from a file. whsec_abc\n is not whsec_abc.
  • Clock skew breaking a timestamp tolerance on a server whose clock has drifted.
  • A proxy mutating the body between the edge and your handler.

When verification fails and you cannot tell which of these it is, the fastest path is to look at the exact request that failed — the raw body, the signature header, the timestamp — and recompute by hand. That is easier when something already captured those bytes; see working a failed delivery step by step and the signature mismatch error reference.

Verifying at the edge

You can also verify before the request reaches your service. A HookWatch endpoint can check inbound signatures using either its own scheme (X-Hookwatch-Signature, HMAC-SHA256) or a custom scheme configured to match your provider: header name, algorithm (SHA-1, SHA-256, or SHA-512), encoding (hex or base64), and an optional prefix like sha256=. Provider secrets are stored encrypted, and rotation uses the 24-hour overlap window described above. Because the endpoint records the full raw request for every delivery, a signature that fails is something you can inspect byte for byte rather than reproduce from guesses.

Whether you verify in your handler or at the edge, keep the invariant fixed: hash the raw body, fold in the timestamp if the provider signs one, compare in constant time, and let a rotation overlap window carry you across secret changes. Once you can verify events, the next thing to get right is testing them locally before they ever reach production.

Get started

See what happened to every webhook.

HookWatch keeps the request, the response, and every attempt for each delivery — so the debugging, retry, and replay steps in this article are a matter of reading, not reconstructing.