A fake webhook attack sends a crafted JSON payload to your webhook URL with event type charge.success and a fabricated transaction reference. If your handler trusts the payload without checking the x-paystack-signature header, it grants value for a payment that never happened. Defend by computing the HMAC-SHA512 hash of the raw request body using your secret key and comparing it to the signature header. Reject any request where they do not match.
Your webhook URL is an HTTP endpoint that receives POST requests from Paystack when events happen (payment completed, transfer successful, subscription renewed). The body of the request is a JSON object describing the event.
An attacker discovers or guesses your webhook URL. Then they send a POST request that looks exactly like a legitimate Paystack webhook:
// What the attacker sends to your webhook URL
{
"event": "charge.success",
"data": {
"id": 999999999,
"reference": "fake_ref_abc123",
"amount": 500000,
"currency": "NGN",
"status": "success",
"customer": {
"email": "attacker@example.com"
}
}
}
If your webhook handler blindly trusts this payload, it marks the order as paid, grants access, ships the product, or activates the subscription. The attacker gets value without paying.
The attack is trivial to execute. Any tool that can send HTTP requests (curl, Postman, a simple script) can do it. The attacker does not need your API keys, database access, or any special skill. They just need your webhook URL and a correctly shaped JSON body.
Every legitimate webhook from Paystack includes an x-paystack-signature header. This header contains the HMAC-SHA512 hash of the request body, computed using your Paystack secret key.
HMAC (Hash-based Message Authentication Code) works like this:
x-paystack-signature headerOn your server:
x-paystack-signature headerThe security relies on the fact that only Paystack and your server know the secret key. An attacker who does not have the secret key cannot compute the correct hash, so they cannot produce a valid signature.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Here is the complete implementation for an Express.js webhook handler:
var crypto = require('crypto');
var express = require('express');
var app = express();
// CRITICAL: Use raw body, not parsed JSON
app.post('/webhook/paystack',
express.raw({ type: 'application/json' }),
function(req, res) {
var secret = process.env.PAYSTACK_SECRET_KEY;
var signature = req.headers['x-paystack-signature'];
if (!signature) {
console.log('Webhook rejected: no signature header');
return res.status(400).json({ error: 'No signature' });
}
var hash = crypto
.createHmac('sha512', secret)
.update(req.body)
.digest('hex');
if (hash !== signature) {
console.log('Webhook rejected: invalid signature');
return res.status(400).json({ error: 'Invalid signature' });
}
// Signature is valid. Parse the body and process the event.
var event = JSON.parse(req.body.toString());
console.log('Webhook verified: ' + event.event);
// Process the event
if (event.event === 'charge.success') {
handleChargeSuccess(event.data);
}
res.status(200).json({ received: true });
}
);
Key details:
express.raw() gives you the raw request body as a Buffer. This is essential. If you use express.json(), Express parses the JSON and you lose the exact byte sequence..update(req.body) call takes the raw Buffer. Do not convert to string or parse to JSON before hashing.Developers make these mistakes repeatedly, each of which breaks the signature check and leaves the door open to fake webhooks:
// WRONG: parsing first, then re-stringifying
app.post('/webhook', express.json(), function(req, res) {
var hash = crypto
.createHmac('sha512', secret)
.update(JSON.stringify(req.body)) // re-serialized, different bytes
.digest('hex');
});
JSON.stringify may change whitespace, key order, or Unicode escaping compared to the original body. The hash will not match even for legitimate webhooks. Always hash the raw body.
// WRONG: "will add verification later"
app.post('/webhook', function(req, res) {
// TODO: verify signature
handlePayment(req.body.data);
res.status(200).send('ok');
});
"Temporarily" becomes permanently in many codebases. Never ship a webhook handler without signature verification, not even to staging.
// VULNERABLE: standard string comparison leaks timing information
if (hash === signature) { ... }
In theory, standard string comparison can leak information about how many characters match through timing differences. For webhook verification with a 128-character hex string, the practical risk is very low, but you can use crypto.timingSafeEqual for defense in depth:
var hashBuffer = Buffer.from(hash, 'hex');
var sigBuffer = Buffer.from(signature, 'hex');
var isValid = hashBuffer.length === sigBuffer.length
&& crypto.timingSafeEqual(hashBuffer, sigBuffer);
If the x-paystack-signature header is missing, your hash comparison will compare against undefined, which may pass in some languages. Always check that the header exists before comparing.
Signature verification proves the webhook came from Paystack. But for critical operations (granting access, shipping products, activating subscriptions), add a second verification step: call the Paystack API to confirm the transaction.
var https = require('https');
function verifyTransaction(reference, callback) {
var options = {
hostname: 'api.paystack.co',
path: '/transaction/verify/' + reference,
method: 'GET',
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
};
var req = https.request(options, function(res) {
var data = '';
res.on('data', function(chunk) { data += chunk; });
res.on('end', function() {
var result = JSON.parse(data);
callback(null, result);
});
});
req.on('error', function(err) { callback(err); });
req.end();
}
function handleChargeSuccess(webhookData) {
verifyTransaction(webhookData.reference, function(err, result) {
if (err) {
console.error('Verification API call failed:', err.message);
return;
}
if (!result.status || result.data.status !== 'success') {
console.error('Transaction verification failed for: ' + webhookData.reference);
return;
}
// Both checks passed. Safe to grant value.
grantAccess(result.data);
});
}
This second check catches scenarios that signature verification alone does not cover:
Signature verification is your primary defense, but you should also minimize exposure of your webhook URL:
/webhook or /paystack-webhook, use something like /hooks/ps-v2-a7f3e9. This does not provide security (that comes from signatures), but it reduces noise from scanners.Write tests that confirm your webhook handler rejects invalid signatures. Here is a Jest test:
var crypto = require('crypto');
var request = require('supertest');
var app = require('../app');
var SECRET = 'sk_test_xxxxxxxxxxxxxxxxxxxx';
function sign(body) {
return crypto
.createHmac('sha512', SECRET)
.update(body)
.digest('hex');
}
describe('Webhook signature verification', function() {
it('accepts valid signatures', function(done) {
var body = JSON.stringify({
event: 'charge.success',
data: { reference: 'test_ref_123', status: 'success', amount: 50000 }
});
request(app)
.post('/webhook/paystack')
.set('Content-Type', 'application/json')
.set('x-paystack-signature', sign(body))
.send(body)
.expect(200, done);
});
it('rejects missing signatures', function(done) {
request(app)
.post('/webhook/paystack')
.set('Content-Type', 'application/json')
.send('{"event":"charge.success"}')
.expect(400, done);
});
it('rejects invalid signatures', function(done) {
request(app)
.post('/webhook/paystack')
.set('Content-Type', 'application/json')
.set('x-paystack-signature', 'invalid_signature_value')
.send('{"event":"charge.success"}')
.expect(400, done);
});
});
These tests should be part of your CI pipeline. See contract testing your Paystack webhook handler for comprehensive webhook testing strategies.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course