Skip to content

Security

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.

3 min readPublished Updated

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:

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

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

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

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 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 and the Callbacks docs. The 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_.

Read next

This guide is part of the Cloudflare Turnstile solver hub. Every task is charged only when a token is ready.

Get an API key