NxtBanking — NxtBanking — API Infrastructure for Indian Fintech

Webhook Security: HMAC Signature Verification for Payment APIs

Updated April 23, 2026 min read

NxtBanking Editorial

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_id with 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

← All insights

Ready when you are

Go live on NxtBanking rails.

Tell us which rails you need — payouts, AEPS, BBPS, UPI, KYC, or the full stack. Our solution architects map the right APIs to your use case, usually within a day.