Developers · · 1 min read
Verifying webhook signatures
Why KYCVerify signs a timestamp with the body, how to verify it correctly in Node.js, Python, Go, Rust and PHP, and the mistakes that break it.
A webhook endpoint is a public URL that changes state in your system: a verification was approved, so the account is unlocked. Anyone who finds the URL can post to it. A signature proves the request came from your KYCVerify server and was not altered or replayed.
The scheme
x-kyc-timestamp: 1767225600
x-kyc-signature: v1=<hex(HMAC-SHA256(webhook_secret, "{timestamp}.{raw_body}"))>- HMAC-SHA256 with the app's webhook secret (
whsec_…) as the key. - The signed message is the timestamp, a dot, then the exact bytes of the body.
v1=versions the scheme, so it can change without breaking receivers.
Signed message
1767225600.{"event":…}
HMAC-SHA256
keyed with whsec_… (per app)
Hex digest
64 lowercase hex characters
v1=<hex>
sent as x-kyc-signature
- Recompute over the raw body, before parsing JSON
- Compare in constant time
- Reject timestamps more than 300 s from now
Why sign the timestamp
Signing only the body would let an attacker who captured one delivery replay it forever. Binding the timestamp into the signature and rejecting anything more than 5 minutes old limits a replay to that window; making your handler idempotent on delivery_id closes it.
Verify in Node.js
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
// Keep the raw bytes: the signature is over them, not over re-serialised JSON.
app.post("/webhooks/kyc", express.raw({ type: "application/json" }), (req, res) => {
const ts = Number(req.get("x-kyc-timestamp"));
if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) return res.sendStatus(400);
const expected = createHmac("sha256", process.env.KYC_WEBHOOK_SECRET)
.update(`${ts}.`)
.update(req.body) // Buffer
.digest("hex");
const given = (req.get("x-kyc-signature") ?? "").replace(/^v1=/, "");
const ok = given.length === expected.length &&
timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
queue.add(event); // do the work asynchronously
res.sendStatus(204);
});Verify in Python
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["KYC_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/kyc")
def kyc_webhook():
ts = request.headers.get("x-kyc-timestamp", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
abort(400)
raw = request.get_data() # bytes, before any JSON parsing
expected = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
given = request.headers.get("x-kyc-signature", "").removeprefix("v1=")
if not hmac.compare_digest(given, expected):
abort(401)
enqueue(request.get_json())
return "", 204Go, Rust and PHP
The same three rules in every language: raw bytes, constant-time comparison, a 300-second window.
Verify x-kyc-signature
package kyc
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// VerifyWebhook reads the body and checks x-kyc-signature over "{timestamp}.{raw_body}".
func VerifyWebhook(r *http.Request, secret string) ([]byte, bool) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
return nil, false
}
ts := r.Header.Get("x-kyc-timestamp")
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil || abs(time.Now().Unix()-sec) > 300 {
return nil, false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
given := strings.TrimPrefix(r.Header.Get("x-kyc-signature"), "v1=")
return body, hmac.Equal([]byte(given), []byte(expected))
}
func abs(n int64) int64 {
if n < 0 {
return -n
}
return n
}A test vector
Check your implementation against the value pinned in KYCVerify's own tests:
printf '1700000000.{"a":1}' | openssl dgst -sha256 -hmac whsec_test
# 38877139021993b830af32feea6e18a8da83eb2f6e49ee50bd9e4cf4ca4d3789Mistakes that break verification
- Parsing before verifying. Body parsers re-serialise JSON with different spacing or key order. Always sign-check the raw bytes.
- Comparing with `==`. Ordinary string comparison returns early and leaks timing. Use
timingSafeEqualorhmac.compare_digest. - Skipping the timestamp check. Without it, a captured delivery can be replayed indefinitely.
- Trusting `in_review` as final. It means a person will decide; another event follows.
- Doing slow work inline. A handler that takes longer than 10 seconds counts as a failure and the delivery is retried.
- Rejecting retries as replays. Every attempt is signed when it is sent, so a retry hours later carries a fresh timestamp. Deduplicate on
delivery_id, not on the timestamp.
Rotating the secret
Attempt 1
0
on the status change
Attempt 2
+30 s
30 s after the last
Attempt 3
+2 m 30 s
2 min after the last
Attempt 4
+12 m 30 s
10 min after the last
Attempt 5
+42 m 30 s
30 min after the last
Attempt 6
+1 h 42 m
1 h after the last
Attempt 7
+4 h 42 m
3 h after the last
Attempt 8
+10 h 42 m
6 h after the last
Any 2xx within 10 s
delivery succeeded; no more attempts
Anything else
non-2xx, timeout, refused: next attempt is scheduled
After attempt 8 fails
marked failed; redeliver from the console
Rotate from the console's Webhooks page. The new secret is shown once; deploy it to your receiver promptly, then use the delivery log to redeliver anything that failed during the switch.