Webhook Security: How to Verify Crypto Payment Callbacks
Why Webhook Security Is Critical for Payment Systems
When you integrate a crypto payment gateway like ChainPay, your server receives webhook callbacks every time a payment is confirmed on the blockchain. These callbacks trigger critical business logic: activating subscriptions, granting access to products, crediting user accounts, or updating order statuses.
Now imagine this: an attacker sends a fake webhook to your endpoint claiming that a $500 payment was completed. If your server blindly trusts this callback, it will grant $500 worth of product or service to someone who never paid. An insecure webhook endpoint is essentially an open door to your fulfillment system.
This article covers everything you need to know about securing your crypto payment webhooks — from HMAC signature verification to replay attack prevention to production hardening. Every code example uses ChainPay's webhook system, but the principles apply to any payment callback integration.
Understanding the Threat Model
Before writing any security code, you need to understand what you are defending against. Here are the primary attack vectors for payment webhooks:
- Webhook forgery. An attacker crafts a fake HTTP POST request to your webhook endpoint with a fabricated payment confirmation. If your server does not verify the origin, it processes the fake payment as real.
- Replay attacks. An attacker intercepts a legitimate webhook payload and re-sends it to your endpoint multiple times. Without replay protection, your server might fulfill the same order repeatedly.
- Timing attacks. When comparing signature strings, a naive string comparison can leak information about the expected signature through timing differences. An attacker can use this to forge valid signatures character by character.
- Man-in-the-middle modification. An attacker intercepts a webhook in transit and modifies the payload (e.g., changing the amount or order ID) while keeping the original signature. This is why the signature must cover the entire body.
A properly secured webhook endpoint defends against all of these attacks simultaneously. Let us build one step by step.
Step 1: HMAC-SHA256 Signature Verification
HMAC (Hash-based Message Authentication Code) is the industry standard for webhook verification. The concept is simple: ChainPay and your server share a secret key. ChainPay uses this key to generate a signature of the webhook body, and your server uses the same key to verify it.
Here is how ChainPay's signature works:
- ChainPay serializes the webhook payload as JSON
- It computes
HMAC-SHA256(webhook_secret, raw_body) - The resulting hex digest is sent in the
x-chainpay-signatureheader - Your server performs the same computation and compares the result
Here is the implementation in Node.js:
import crypto from 'crypto';
function verifyWebhookSignature(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// Use timing-safe comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
}Critical detail: Use crypto.timingSafeEqual() instead of === for comparing signatures. A regular string comparison returns false as soon as it finds a mismatched character, which means different wrong signatures take different amounts of time to reject. An attacker can measure these timing differences to reconstruct the valid signature one character at a time. timingSafeEqual always takes the same amount of time regardless of where the strings differ.
Here is the equivalent in Python:
import hmac
import hashlib
def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode('utf-8'),
raw_body,
hashlib.sha256
).hexdigest()
# hmac.compare_digest is timing-safe
return hmac.compare_digest(signature, expected)Step 2: Replay Attack Prevention
Even with valid signature verification, an attacker who captures a legitimate webhook can replay it. If a customer paid $100 for a product, replaying the webhook 10 times could grant them $1,000 worth of access.
There are two complementary strategies to prevent replay attacks:
Strategy A: Idempotency keys. Store the unique identifier from each processed webhook and reject duplicates. ChainPay includes a unique event_id in every webhook payload:
import crypto from 'crypto';
// Use a database or Redis to track processed events
const processedEvents = new Set(); // In production, use Redis or DB
export async function POST(request) {
const body = await request.text();
const signature = request.headers.get('x-chainpay-signature');
// Step 1: Verify signature
if (!verifyWebhookSignature(body, signature, process.env.WEBHOOK_SECRET)) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(body);
// Step 2: Check for replay
if (processedEvents.has(event.event_id)) {
// Already processed — return 200 to prevent retries
return new Response('Already processed', { status: 200 });
}
// Step 3: Process the event
if (event.type === 'payment.completed') {
await fulfillOrder(event.data.merchant_order_id);
}
// Step 4: Mark as processed
processedEvents.add(event.event_id);
return new Response('OK', { status: 200 });
}Strategy B: Timestamp validation. Reject webhooks that are too old. If a webhook was sent 5 minutes ago and is arriving now, it might be a replay. ChainPay includes a timestamp field in the payload:
function isTimestampValid(timestamp, toleranceSeconds = 300) {
const webhookTime = new Date(timestamp).getTime();
const now = Date.now();
const diff = Math.abs(now - webhookTime);
return diff < toleranceSeconds * 1000;
}
// In your webhook handler:
const event = JSON.parse(body);
if (!isTimestampValid(event.timestamp)) {
return new Response('Webhook too old', { status: 400 });
}Use both strategies together for maximum protection. The idempotency check catches exact replays, while the timestamp check catches replays of old webhooks that might not be in your idempotency cache anymore.
Step 3: Use the Raw Request Body
A subtle but critical detail: always compute the HMAC signature on the raw request body, not on a re-serialized version. JSON serialization is not deterministic — different JSON libraries may produce different byte-for-byte output for the same logical object (different key ordering, different whitespace, different number formatting).
If you parse the body to JSON and then re-stringify it before computing the signature, the result may differ from ChainPay's signature even though the data is identical:
// WRONG — do not do this
const parsed = await request.json();
const body = JSON.stringify(parsed); // May differ from original!
const sig = hmac(body); // Will not match
// CORRECT — use the raw body
const body = await request.text(); // Raw bytes as sent by ChainPay
const sig = hmac(body); // Will match
const parsed = JSON.parse(body); // Parse after verificationIn frameworks like Next.js, Express, or Fastify, make sure your middleware does not automatically parse the JSON body before your webhook handler gets it. With Next.js App Router, using request.text() gives you the raw body correctly.
Step 4: Secure Your Endpoint Infrastructure
Beyond signature verification, these infrastructure practices harden your webhook endpoint:
- HTTPS only. Never expose your webhook endpoint over plain HTTP. TLS encryption prevents man-in-the-middle attacks from reading or modifying webhook payloads in transit.
- Return 200 quickly. Process the webhook asynchronously if your fulfillment logic is slow. ChainPay will retry webhooks that receive non-2xx responses, which can cause duplicate processing if your handler is too slow and times out.
- Rate limit your endpoint. While ChainPay sends webhooks at a reasonable rate, an attacker brute-forcing your endpoint with invalid signatures should be rate-limited to prevent resource exhaustion.
- Log all webhook activity. Keep a log of every webhook received (with timestamp, event ID, signature validity, and processing result). This is invaluable for debugging and auditing.
- Use environment variables for secrets. Never hardcode your webhook secret in source code. Store it in environment variables or a secret manager.
Complete Production-Ready Webhook Handler
Here is a complete, production-ready webhook handler that implements all the security measures discussed:
import crypto from 'crypto';
import { redis } from '@/lib/redis';
import { db } from '@/lib/database';
const TIMESTAMP_TOLERANCE = 300; // 5 minutes
const EVENT_TTL = 86400; // 24 hours
function verifySignature(body, signature, secret) {
try {
const expected = crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
} catch {
return false;
}
}
export async function POST(request) {
const body = await request.text();
const signature = request.headers.get('x-chainpay-signature');
// 1. Reject if no signature header
if (!signature) {
console.warn('[webhook] Missing signature header');
return new Response('Missing signature', { status: 401 });
}
// 2. Verify HMAC-SHA256 signature (timing-safe)
if (!verifySignature(body, signature, process.env.WEBHOOK_SECRET)) {
console.warn('[webhook] Invalid signature');
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(body);
// 3. Validate timestamp (reject old webhooks)
const webhookAge = Math.abs(Date.now() - new Date(event.timestamp).getTime());
if (webhookAge > TIMESTAMP_TOLERANCE * 1000) {
console.warn(`[webhook] Stale webhook: ${webhookAge}ms old`);
return new Response('Webhook expired', { status: 400 });
}
// 4. Check idempotency (prevent replay attacks)
const eventKey = `webhook:${event.event_id}`;
const alreadyProcessed = await redis.get(eventKey);
if (alreadyProcessed) {
return new Response('Already processed', { status: 200 });
}
// 5. Process the event
try {
if (event.type === 'payment.completed') {
await db.orders.update({
where: { id: event.data.merchant_order_id },
data: { status: 'paid', paidAt: new Date() }
});
await fulfillOrder(event.data.merchant_order_id);
}
// 6. Mark as processed with TTL
await redis.set(eventKey, '1', 'EX', EVENT_TTL);
console.log(`[webhook] Processed ${event.type}: ${event.event_id}`);
return new Response('OK', { status: 200 });
} catch (error) {
console.error(`[webhook] Processing failed:`, error);
// Return 500 so ChainPay retries
return new Response('Processing failed', { status: 500 });
}
}Testing Your Webhook Security
Before going to production, test these scenarios:
- No signature header. Send a POST without
x-chainpay-signature. Expect 401. - Wrong signature. Send a POST with a random hex string as the signature. Expect 401.
- Modified body. Take a valid webhook, change one byte in the body, keep the original signature. Expect 401.
- Replay. Send the same valid webhook twice. Expect 200 both times, but the order should only be fulfilled once.
- Old timestamp. Send a webhook with a timestamp from 10 minutes ago. Expect 400.
- Valid webhook. Send a properly signed, fresh webhook. Expect 200 and order fulfillment.
You can generate test signatures locally using the same HMAC function:
// Generate a test webhook signature
const testBody = JSON.stringify({
event_id: 'test_001',
type: 'payment.completed',
timestamp: new Date().toISOString(),
data: {
merchant_order_id: 'order_test',
amount: 49.99,
currency: 'USDT_TRC20'
}
});
const testSig = crypto
.createHmac('sha256', 'your_webhook_secret')
.update(testBody)
.digest('hex');
console.log('Body:', testBody);
console.log('Signature:', testSig);Secure Your Payment Integration with ChainPay
Webhook security is not optional — it is the foundation of a reliable payment integration. ChainPay provides HMAC-SHA256 signed webhooks out of the box, giving you the cryptographic primitives you need to build a secure system. Combined with the idempotency and timestamp strategies in this guide, your payment callbacks will be bulletproof.
Ready to build a secure crypto payment integration? Get started at chainpay.pro and check the API documentation for complete webhook reference and testing tools.