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…e91atis the time the call was signed, in Unix seconds.v1is 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
- 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.
- Comparing with
==. An ordinary string comparison stops at the first different byte, which leaks timing information. UsetimingSafeEqual,hmac.compare_digestorhmac.Equal. - 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.