Webhook Security: HMAC Signature Verification for Payment APIs
Every payment-API integration relies on webhooks for the terminal status of
a transaction. If you skip signature verification, an attacker who discovers
your webhook URL can forge “payment succeeded” events and take over your
business logic. This guide covers the HMAC-SHA256 pattern used by
NxtBanking, Razorpay, Stripe, Cashfree and most serious payment
APIs, and the three attack classes you have to defend against.
1. What HMAC actually gives you
HMAC-SHA256 is a keyed hash. Sender and receiver share a secret
S. The sender computes hex(HMAC_SHA256(S, raw_body))
and ships it in an X-Signature header. The receiver computes the
same thing and compares. If they match:
- The body has not been tampered with (integrity).
- The sender knew the shared secret (authenticity).
What it does not give you:
(a) confidentiality — the body is still plain JSON;
(b) replay protection — an attacker who captured a valid request can resend it.
We fix (b) below.
2. Three attack classes you must handle
A. Forgery. Attacker hits your endpoint with arbitrary JSON.
Defeated by HMAC.
B. Replay. Attacker captured a valid “SUCCESS” webhook and
replays it to credit a customer twice. Defeated by a
X-Timestamp header + rejecting anything older than 5 minutes +
idempotency on event_id.
C. Timing side-channel. Naive string comparison
(===) leaks byte-by-byte timing. Defeated by constant-time compare
(hash_equals in PHP, crypto.timingSafeEqual in Node,
hmac.compare_digest in Python).
3. The canonical verification function (PHP)
function verify_webhook(string $raw_body, string $sig_header,
string $ts_header, string $secret, int $max_skew = 300): bool {
// 1. Timestamp freshness
if (abs(time() - (int)$ts_header) > $max_skew) return false;
// 2. HMAC over "timestamp.body" prevents signature replay across endpoints
$signing_payload = $ts_header . '.' . $raw_body;
$expected = hash_hmac('sha256', $signing_payload, $secret);
// 3. Constant-time compare
return hash_equals($expected, $sig_header);
}4. Node.js version
const crypto = require('crypto');
function verifyWebhook(rawBody, sigHeader, tsHeader, secret, maxSkewSec = 300) {
if (Math.abs(Date.now()/1000 - Number(tsHeader)) > maxSkewSec) return false;
const payload = `${tsHeader}.${rawBody}`;
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
const a = Buffer.from(sigHeader, 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}5. Python version
import hmac, hashlib, time
def verify_webhook(raw_body: bytes, sig_header: str, ts_header: str,
secret: str, max_skew: int = 300) -> bool:
if abs(int(time.time()) - int(ts_header)) > max_skew:
return False
payload = f"{ts_header}.".encode() + raw_body
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig_header)6. The five mistakes we still see in code reviews
- Parsing JSON before verifying. Frameworks like Express, Fastify and Django reformat the body during JSON parsing — your computed HMAC then differs from the sender’s. Always capture the raw body first (in Express, use
express.raw({type:'application/json'})). - Using
===instead of constant-time compare. Opens a practical timing attack in Node/Python; exploitable over a LAN. - Trusting the timestamp sent in the body. The timestamp must be on a signed header, not inside the JSON payload the attacker can rewrite.
- No idempotency store. Even with signatures, a retried legitimate event will fire twice. Persist
event_idwith 7-day retention. - Secret logged in plaintext. If the secret ends up in your log aggregator, an insider can forge webhooks. Redact via a central logger.
7. Rotating the secret
Plan for rotation on day one — don’t wait for a breach. The cleanest pattern
is two active secrets for a short overlap window:
- Provider generates
secret_v2; emits webhooks signed with both v1 and v2 headers (X-Signature-v1,X-Signature-v2). - You update your verifier to accept either.
- After 24 h, provider stops signing with v1.
- You disable v1 acceptance.
8. Beyond HMAC — when do you need mTLS?
For extremely high-value flows (RBI-regulated settlement files, NACH debit
confirmations) a mutual TLS channel between provider and merchant
is standard. HMAC still runs on top. mTLS authenticates the TCP endpoint; HMAC
authenticates individual events. Most fintechs do not need mTLS for retail
payment webhooks — HMAC + replay protection is the well-established bar.
Where to go next
- UPI API Integration Guide — where webhook verification plugs in.
- Payout RBI Compliance Checklist — why webhook authenticity is an audit-trail requirement.
- Integrate BBPS API for Bill Payments — another webhook-heavy flow.