# Verify a Webhook's HMAC-SHA256 Signature, With Replay Protection

> Check a ZeroCaptcha callback's HMAC-SHA256 signature in Node, Python or Go: the raw body, a constant-time compare, the timestamp window and secret rotation.

- Source: https://zerocaptcha.io/guides/verify-webhook-hmac-signature
- Published: 2026-09-30
- Updated: 2026-10-01
- Author: ZeroCaptcha Engineering

A callback URL is public: anyone who learns it can post to it. Before your server trusts a
callback's token or error, it should prove the call came from ZeroCaptcha and was not recorded and
replayed. Every callback carries an HMAC-SHA256 signature for exactly that. This guide explains
the scheme, the three mistakes that break it, and working code for Node, Python and Go.

## The scheme

Each call has a header like this:

```text
ZeroCaptcha-Signature: t=1790754004,v1=5f0c…e91a
```

- `t` is the time the call was signed, in Unix seconds.
- `v1` is the hex-encoded HMAC-SHA256 of the string `<t>.<raw body>`, keyed with your account's
  callback secret.

To check it: rebuild the signed string from the timestamp and the body as received, compute the
HMAC with your secret, compare the result with `v1` in constant time, and refuse the call if the
timestamp is more than five minutes from your clock.

Your secret starts with `zcsig_`. Owners of the account find it on the dashboard's API keys page,
under **Callback signing secret**. Store it like any other secret, in your secret manager or
environment, never in source control.

## Node

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function isGenuine(secret, header, rawBody) {
  const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? "");
  if (!match || Math.abs(Date.now() / 1000 - Number(match[1])) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${match[1]}.`).update(rawBody).digest();
  return timingSafeEqual(expected, Buffer.from(match[2], "hex"));
}
```

With Express, read the body raw for this route, for example with
`express.raw({ type: "*/*" })`, so `rawBody` is a Buffer of the bytes that arrived.

## Python

```python
import hashlib, hmac, re, time

def is_genuine(secret: str, header: str, raw_body: bytes) -> bool:
    match = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "")
    if not match or abs(time.time() - int(match[1])) > 300:
        return False
    expected = hmac.new(secret.encode(), match[1].encode() + b"." + raw_body, hashlib.sha256)
    return hmac.compare_digest(expected.hexdigest(), match[2])
```

In Flask, use `request.get_data()`; in Django, `request.body`; in FastAPI, `await request.body()`.
All three give the raw bytes.

## Go

```go
func isGenuine(secret, header string, rawBody []byte) bool {
	match := regexp.MustCompile(`^t=(\d+),v1=([0-9a-f]{64})$`).FindStringSubmatch(header)
	if match == nil {
		return false
	}
	sent, _ := strconv.ParseInt(match[1], 10, 64)
	if age := time.Since(time.Unix(sent, 0)); age > 5*time.Minute || age < -5*time.Minute {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(match[1] + "."))
	mac.Write(rawBody)
	given, _ := hex.DecodeString(match[2])
	return hmac.Equal(mac.Sum(nil), given)
}
```

The [SDKs](https://zerocaptcha.io/docs/sdks) for JavaScript, Python and Go include this check as `verifySignature`,
`verify_signature` and `VerifySignature`.

## The three mistakes

1. **Checking a parsed body.** Frameworks that parse JSON before your handler runs hand you an
   object, not the bytes that were signed. Re-serializing it rarely reproduces them exactly. Read
   the raw body for the callback route.
2. **Comparing with `==`.** An ordinary string comparison stops at the first different byte,
   which leaks timing information. Use `timingSafeEqual`, `hmac.compare_digest` or `hmac.Equal`.
3. **Skipping the timestamp.** Without the five-minute window, a recorded call stays valid
   forever. Check it, and keep your server's clock synchronized with NTP.

## Handle repeats too

A valid call can still arrive twice: ZeroCaptcha retries a call whose answer it did not get. Each
call carries a `ZeroCaptcha-Delivery` header whose value is the same on every attempt. Record it,
or the task ID, and skip ones you have already handled. That is separate from the signature
check: a replayed call within five minutes passes the signature and is caught here.

## Rotating the secret

An owner can rotate the secret on the API keys page. The new secret signs every call from then on,
including retries of earlier tasks, and the old one stops matching at once. So deploy the new
secret to your receiver as soon as you copy it, and expect a few refused calls in between, which
are retried. Each rotation is listed in the team's activity.

## What to do with a refused call

Answer `401` and log the attempt without the body. A genuine call that you refused by mistake is
retried, and its result also stays readable by polling. The task itself is unaffected: the
signature protects your endpoint, not the solve.

More on callbacks, including what each format sends, is in
[Captcha solver callbacks](https://zerocaptcha.io/guides/captcha-solver-callbacks) and the [Callbacks](https://zerocaptcha.io/docs/callbacks)
docs. The [Cloudflare Turnstile solver](https://zerocaptcha.io/cloudflare-turnstile-solver) page shows the whole flow.

## Questions

### Why must I use the raw body to check the signature?

The signature covers the exact bytes that were sent. Parsing and re-serializing JSON can change spacing or key order, and then the signature no longer matches.

### What does the timestamp in the signature protect against?

Replays. A recorded call carries its old timestamp, so refusing calls more than five minutes from now stops anyone from sending it again later.

### Where do I find my callback signing secret?

Owners of the account see it on the dashboard's API keys page, under Callback signing secret. It starts with zcsig_.
