developer
How to Verify UPI Webhook Signatures: Preventing Replay Attacks and Payload Forgery
A security engineer's guide to verifying UPI payment webhook signatures. Learn HMAC-SHA256 math, timestamp tolerance windows, and constant-time comparisons.
When an e-commerce platform accepts online payments, its webhook receiver is a publicly exposed HTTPS endpoint. If that endpoint is not fortified with rigorous cryptographic checks, bad actors can forge fake payment confirmation payloads, artificially crediting wallets and stealing physical merchandise.
Here is the authoritative security engineering guide to validating HMAC-SHA256 webhook signatures, enforcing timestamp tolerance windows, and preventing replay attacks.
The Anatomy of a Payment Webhook Exploit
Direct Answer: An insecure webhook listener that validates payments solely on the JSON body (
status: "SUCCESS") without verifying cryptographic headers can be exploited by anyone capable of sending a basic cURL request. Attackers simply send fake payment events directly to your callback URL, tricking your system into fulfilling orders without paying a single rupee.
To guarantee zero fraud, your server must enforce Message Integrity (the payload was not altered) and Origin Authenticity (the request came from VyaparGateway).
The HMAC-SHA256 Cryptographic Contract
VyaparGateway signs every outbound webhook using the HMAC-SHA256 standard.
The Header Structure:
POST /api/webhooks/payment HTTP/1.1
Host: yourstore.com
Content-Type: application/json
X-Webhook-Signature: t=1728038400,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
t: The UNIX timestamp (in seconds) when the gateway dispatched the event.v1: The computed HMAC-SHA256 hexadecimal signature of the signed payload.
The Signed Payload Construction:
The signature is computed over the string:
signed_payload = timestamp + "." + raw_body_bytes
Defeating Replay Attacks with Timestamp Tolerances
Even if an attacker cannot forge your secret key, they might intercept a genuine webhook and replay it 2 days later.
To eliminate this vulnerability:
- Extract
tfrom the header. - Compare it against your current server timestamp:
current_time = now() if (current_time - t) > 300 seconds: REJECT ("Timestamp outside 5-minute tolerance window") - This 300-second window accounts for network latency and NTP server drift while rendering captured packets useless to attackers.
Complete Multi-Language Verification Snippets
1. Node.js / TypeScript (Express)
import crypto from 'crypto';
import { Request, Response } from 'express';
export function verifyWebhook(req: Request, res: Response, secret: string): boolean {
const signatureHeader = req.headers['x-webhook-signature'] as string;
if (!signatureHeader) return false;
const parts = Object.fromEntries(
signatureHeader.split(',').map((item) => item.split('='))
);
const timestamp = parseInt(parts.t, 10);
const receivedSig = parts.v1;
// 1. Check timestamp tolerance (5 minutes)
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - timestamp) > 300) {
return false; // Stale or replayed webhook
}
// 2. Reconstruct signed payload using RAW BODY buffer
const rawBody = (req as any).rawBody.toString('utf-8');
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// 3. Constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(receivedSig, 'hex'),
Buffer.from(expectedSig, 'hex')
);
}
2. Python (FastAPI / Standard Library)
import hmac
import hashlib
import time
def verify_webhook_signature(raw_body: bytes, header_sig: str, secret: str) -> bool:
try:
parts = dict(x.split("=") for x in header_sig.split(","))
timestamp = int(parts["t"])
received_sig = parts["v1"]
except Exception:
return False
# Check 5-minute replay tolerance
if abs(int(time.time()) - timestamp) > 300:
return False
# Create signed message
signed_payload = f"{timestamp}.".encode("utf-8") + raw_body
expected_sig = hmac.new(
key=secret.encode("utf-8"),
msg=signed_payload,
digestmod=hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(received_sig, expected_sig)
Security Audit Checklist for Payment Listeners
- Disable Body Parsers for HMAC Path: In Express, use
express.raw({ type: 'application/json' })for your webhook route to retain untouched body bytes. - Store Secrets in Environment Variables: Never hardcode your webhook secret in Git repositories.
- Log Signature Failures with IP Addresses: Alert your SecOps team when an IP repeatedly triggers 401 signature mismatches.
- Return 200 OK Quickly: Acknowledge receipt within 2 seconds, dispatching database updates and third-party API calls to asynchronous background queues.
Test and debug your HMAC signatures in seconds using our interactive Webhook Payload Signature Generator.
Direct answers
Frequently asked questions
- What is an HMAC-SHA256 signature in payment webhooks?
- An HMAC-SHA256 signature is a cryptographic checksum generated by combining the webhook payload's raw body with a secret key known only to the gateway and the merchant, ensuring message authenticity and data integrity.
- What is a webhook replay attack and how is it prevented?
- A replay attack occurs when an eavesdropper intercepts a valid payment webhook and re-sends it to the merchant server. It is prevented by embedding a timestamp in the signature header and enforcing a strict 5-minute validity window.
- Why must timing-safe string comparison be used to verify webhook signatures?
- Standard string equality operators (== or ===) terminate early upon finding the first mismatched character, creating subtle latency differences that attackers exploit via timing attacks. Constant-time comparison ensures identical evaluation duration regardless of string contents.
Build your payment flow
Explore the API and browser-only merchant tools.
Create UPI checkout orders, verify signed events, or test the free calculators and generators without exposing credentials.