A payment webhook arrives at your server claiming an order has been paid. Do you trust it? If you credit the order based on the HTTP call alone — without verifying a cryptographic signature — you have just handed attackers a free way to mark any order as paid.
What Can Go Wrong Without Verification
An unprotected webhook endpoint is trivially exploitable. An attacker only needs to know your callback URL — which is often discoverable from a browser's network tab, a public GitHub repo, or a test checkout — and they can POST a crafted payload:
POST /webhook HTTP/1.1
Content-Type: application/json
{"order_id": "1337", "status": "paid", "amount": "99.99"}If your handler reads $_POST['status'] === 'paid' and credits the order, the attacker just bought $99.99 of goods for free. This is not a theoretical attack; it happens regularly against unprotected payment integrations.
How HMAC-SHA256 Signing Works
HMAC (Hash-based Message Authentication Code) with SHA-256 solves this by making the payload unforgeable without the secret key. The process is:
- The payment gateway knows your api_secret — a secret shared only between you and the gateway.
- Before sending the webhook, it computes:
HMAC-SHA256(timestamp + "." + body, api_secret) - It includes this signature and the timestamp in HTTP headers.
- Your server recomputes the same HMAC and compares. If they match, the payload is authentic.
An attacker who doesn't know your api_secret cannot forge a valid signature — SHA-256 is a one-way function. The timestamp prevents replay attacks: even a valid intercepted webhook becomes useless after 5 minutes.
Implementing Verification: PHP
function verifyPayoraWebhook(string $secret): array {
$ts = $_SERVER['HTTP_X_PAYORA_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_PAYORA_SIGNATURE'] ?? '';
$body = file_get_contents('php://input');
// 1. reject stale timestamps (replay protection)
if (!ctype_digit($ts) || abs(time() - (int)$ts) > 300) {
http_response_code(400); exit('stale timestamp');
}
// 2. recompute and compare — hash_equals() prevents timing attacks
$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expected, $sig)) {
http_response_code(401); exit('invalid signature');
}
return json_decode($body, true);
}
$event = verifyPayoraWebhook('sk_your_secret');
if ($event['status'] === 'paid') {
creditOrder($event['order_id']);
}Implementing Verification: Node.js
import crypto from 'node:crypto';
function verifyPayoraWebhook(req, rawBody, secret) {
const ts = req.headers['x-payora-timestamp'] ?? '';
const sig = req.headers['x-payora-signature'] ?? '';
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return null; // stale
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex');
// timingSafeEqual prevents timing side-channel attacks
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
? JSON.parse(rawBody)
: null;
}Common Mistakes That Break Verification
1. Reading a parsed body instead of the raw bytes. Express's json() middleware re-serialises the JSON, which may reorder keys or change whitespace. Always buffer the raw request body before parsing, and use that for the HMAC.
2. Using === instead of a timing-safe comparison. A naive string comparison leaks timing information that can be exploited to guess the signature byte by byte. Use hash_equals() in PHP or crypto.timingSafeEqual() in Node.
3. Skipping the timestamp check. A valid but old webhook captured by a network observer could be replayed. The 5-minute window (±300 seconds) eliminates this risk while handling reasonable clock drift.
4. Logging the raw body before verification. If your logging pipeline is compromised, logged payloads could be replayed. Verify first, log after.
Beyond the Signature: Idempotency
Verification proves the webhook is authentic; idempotency ensures you don't credit an order twice. Networks retry webhooks on non-200 responses, so your handler may receive the same event multiple times. The solution is to record a webhook_id in your database and skip processing if you've seen it before:
// pseudo-code
$id = $event['webhook_id'];
if (DB::exists('processed_webhooks', $id)) return respond(200);
DB::insert('processed_webhooks', $id);
creditOrder($event['order_id']);Together, signature verification and idempotency give you a payment integration that is both secure and resilient. See the full integration guide for Payora's complete webhook reference.
Comments