Defend against replay attacks by making your webhook handler idempotent. Store every processed transaction reference in your database. Before granting value, check if the reference has already been processed. If it has, return 200 without taking action. This stops both malicious replays and legitimate Paystack retries from causing double processing.
A replay attack is different from a fake webhook attack. In a fake webhook attack, the attacker crafts a new payload and sends it without a valid signature. Signature verification stops this completely.
In a replay attack, the attacker intercepts or captures a real webhook payload that Paystack sent previously. This payload has a valid signature because it was genuinely signed by Paystack. The attacker sends the exact same payload to your server again. Your signature verification passes because the payload has not been modified.
The result: your server processes the same payment event twice. If your webhook handler grants access on charge.success, the attacker gets access twice (or gets a refund processed twice, or gets a subscription extended twice).
How an attacker captures the original webhook:
Signature verification answers one question: "Did Paystack send this payload?" For a replayed webhook, the answer is yes. Paystack did send it. The signature is valid. The payload is authentic. It is just being replayed.
You need a second question: "Have I already processed this event?" This is the idempotency check, and it is the defense against replays.
Signature verification and idempotency are complementary defenses:
| Attack Type | Signature Verification | Idempotency Check |
|---|---|---|
| Fake webhook (forged payload) | Blocks it | Would also block (unknown reference) |
| Replay attack (real payload, resent) | Passes | Blocks it |
| Paystack retry (real payload, retried) | Passes | Prevents double processing |
You need both. Signature verification without idempotency is vulnerable to replays. Idempotency without signature verification is vulnerable to forged payloads. Together, they cover all attack vectors.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
The core pattern: use the transaction reference as a unique key. Before processing, check if you have already processed a webhook for that reference. If you have, return 200 immediately without taking action.
var crypto = require('crypto');
// Assuming a database with a 'processed_webhooks' table
// Columns: reference (unique), event_type, processed_at
async function handleWebhook(req, res) {
// Step 1: Verify signature (see fake webhook attacks guide)
var secret = process.env.PAYSTACK_SECRET_KEY;
var signature = req.headers['x-paystack-signature'];
var hash = crypto
.createHmac('sha512', secret)
.update(req.body)
.digest('hex');
if (hash !== signature) {
return res.status(400).json({ error: 'Invalid signature' });
}
var event = JSON.parse(req.body.toString());
var reference = event.data.reference;
// Step 2: Idempotency check
var existing = await db.query(
'SELECT reference FROM processed_webhooks WHERE reference = $1',
[reference]
);
if (existing.rows.length > 0) {
console.log('Webhook already processed for: ' + reference);
return res.status(200).json({ received: true, duplicate: true });
}
// Step 3: Process the event
if (event.event === 'charge.success') {
await grantAccess(event.data);
}
// Step 4: Record the processed reference
await db.query(
'INSERT INTO processed_webhooks (reference, event_type, processed_at) VALUES ($1, $2, NOW())',
[reference, event.event]
);
return res.status(200).json({ received: true });
}
Return 200 for duplicates. If you return an error for a duplicate webhook, Paystack may keep retrying a legitimately retried webhook, wasting resources on both sides.
What if two identical webhooks arrive at the exact same time? Both threads check the database, both find no existing record, and both proceed to process the payment. You have double processing despite the idempotency check.
This is a race condition, and it is a real concern with Paystack retries and replay attacks. Fix it with a database-level uniqueness constraint:
CREATE TABLE processed_webhooks (
id SERIAL PRIMARY KEY,
reference VARCHAR(255) UNIQUE NOT NULL,
event_type VARCHAR(100) NOT NULL,
processed_at TIMESTAMP DEFAULT NOW()
);
With a UNIQUE constraint on the reference column, the second INSERT will fail with a duplicate key error even if both threads passed the SELECT check. Wrap the insert in a try-catch:
async function processWebhookSafely(event) {
var reference = event.data.reference;
try {
// Try to insert first (atomic operation)
await db.query(
'INSERT INTO processed_webhooks (reference, event_type) VALUES ($1, $2)',
[reference, event.event]
);
} catch (err) {
if (err.code === '23505') {
// Unique violation: already processed
console.log('Duplicate webhook caught by constraint: ' + reference);
return { duplicate: true };
}
throw err;
}
// Only reaches here if insert succeeded (first processing)
if (event.event === 'charge.success') {
await grantAccess(event.data);
}
return { duplicate: false };
}
The INSERT-first approach is more reliable than SELECT-then-INSERT because the database enforces atomicity at the constraint level. No race condition can bypass it.
For high-value transactions, add a third defense layer: verify the transaction through the Paystack API before granting value.
var https = require('https');
function verifyWithPaystack(reference, expectedAmount, 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);
if (!result.status) {
return callback(new Error('Verification failed'));
}
if (result.data.status !== 'success') {
return callback(new Error('Transaction not successful'));
}
if (result.data.amount !== expectedAmount) {
return callback(new Error('Amount mismatch'));
}
callback(null, result.data);
});
});
req.on('error', callback);
req.end();
}
This API call confirms the transaction is real, successful, and for the expected amount. Even if an attacker somehow replays a webhook past your idempotency check, the Paystack API returns the canonical state of the transaction.
Paystack retries webhooks when your server does not respond with a 2xx status code. This is normal and expected behavior. Your idempotency logic should handle retries gracefully without treating them as attacks.
Differences between retries and attacks:
| Property | Paystack Retry | Replay Attack |
|---|---|---|
| Source IP | Paystack servers | Attacker's IP |
| Timing | Minutes to hours after original | Any time |
| Signature | Valid | Valid (same payload) |
| Payload | Identical to original | Identical to original |
Your code should handle both the same way: check if the reference has been processed, and if so, return 200 without re-processing. The distinction matters for logging and alerting, not for code logic.
If you want to detect potential replay attacks (as opposed to Paystack retries), log the source IP of webhook requests and alert if you see webhooks for already-processed references coming from non-Paystack IP addresses.
The processed_webhooks table grows over time. You can safely delete entries older than Paystack's retry window (typically 72 hours), but many teams keep them longer for audit purposes.
-- Delete entries older than 90 days
DELETE FROM processed_webhooks
WHERE processed_at < NOW() - INTERVAL '90 days';
If you keep entries for audit purposes, add an index on the reference column for fast lookups:
CREATE INDEX idx_processed_webhooks_reference
ON processed_webhooks (reference);
Monitor the table size and query performance. For high-volume integrations processing thousands of transactions per day, consider partitioning by date or archiving old records to a separate table.
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