Price Tampering Attacks on Paystack Checkouts
An attacker opens browser DevTools and changes the amount parameter in the PaystackPop.checkout() call from 500000 (5,000 NGN) to 100 (1 NGN). Paystack processes the payment for the tampered amount because Paystack does not know what the "correct" price is. The defense: always verify the amount from the Paystack API response against your database price before granting value.
How Price Tampering Works
Most Paystack integrations use the Inline JS popup on the frontend. The developer embeds the amount in the JavaScript call:
<script>
var popup = new PaystackPop();
popup.checkout({
key: 'pk_live_xxxxxxxxxxxx',
email: 'customer@example.com',
amount: 500000, // 5,000 NGN in kobo
onSuccess: function(transaction) {
window.location.href = '/verify?ref=' + transaction.reference;
}
});
</script>
The attacker opens browser DevTools, sets a breakpoint on the checkout call, and changes the amount:
// Attacker changes 500000 to 100 in the console
popup.checkout({
key: 'pk_live_xxxxxxxxxxxx',
email: 'attacker@example.com',
amount: 100, // 1 NGN instead of 5,000 NGN
onSuccess: function(transaction) {
window.location.href = '/verify?ref=' + transaction.reference;
}
});
Paystack processes the payment for 1 NGN. The transaction succeeds. The attacker's browser redirects to your verification URL with a valid reference. If your server only checks that the transaction status is "success" without checking the amount, the attacker gets the product for 1 NGN instead of 5,000 NGN.
This attack requires zero technical skill beyond opening browser DevTools. Tutorials for it exist on the internet. If your integration does not validate amounts server-side, you will be exploited.
Why Paystack Cannot Prevent This for You
Paystack is a payment processor, not a pricing engine. When it receives a checkout request for 100 kobo, it processes a payment for 100 kobo. It has no way to know that the product should cost 500,000 kobo. Your product catalog, pricing logic, and business rules live in your application, not in Paystack.
This is true for every payment processor, not just Paystack. Stripe, Flutterwave, and every other gateway face the same issue. The responsibility for amount validation always falls on the merchant (you).
The Vulnerable Verification Pattern
Here is what a vulnerable verification endpoint looks like:
// VULNERABLE: does not check amount
app.get('/verify', async function(req, res) {
var reference = req.query.ref;
var paystackResponse = await verifyTransaction(reference);
if (paystackResponse.data.status === 'success') {
// DANGER: granting value without checking the amount
await grantAccess(paystackResponse.data.customer.email);
res.redirect('/success');
} else {
res.redirect('/failed');
}
});
This code checks that the transaction succeeded but never checks that the amount matches the expected price. An attacker who pays 1 NGN gets the same result as a customer who pays 5,000 NGN.
The Secure Verification Pattern
A secure verification endpoint validates the amount and currency against your authoritative price source (your database):
app.get('/verify', async function(req, res) {
var reference = req.query.ref;
// Step 1: Get the order from your database
var order = await db.query(
'SELECT id, amount_kobo, currency, status FROM orders WHERE payment_reference = $1',
[reference]
);
if (!order.rows.length) {
return res.status(404).json({ error: 'Order not found' });
}
if (order.rows[0].status === 'paid') {
return res.redirect('/success'); // Already processed
}
// Step 2: Verify with Paystack
var paystackResponse = await verifyTransaction(reference);
if (paystackResponse.data.status !== 'success') {
return res.redirect('/failed');
}
// Step 3: Validate amount and currency
var paidAmount = paystackResponse.data.amount;
var paidCurrency = paystackResponse.data.currency;
var expectedAmount = order.rows[0].amount_kobo;
var expectedCurrency = order.rows[0].currency;
if (paidAmount !== expectedAmount) {
console.error(
'Amount mismatch for ' + reference +
': expected ' + expectedAmount +
', got ' + paidAmount
);
// Log this as a potential attack
await logSecurityEvent('price_tampering', reference, paidAmount, expectedAmount);
return res.status(400).json({ error: 'Amount mismatch' });
}
if (paidCurrency !== expectedCurrency) {
console.error('Currency mismatch for ' + reference);
return res.status(400).json({ error: 'Currency mismatch' });
}
// Step 4: All checks passed. Grant value.
await db.query(
'UPDATE orders SET status = $1 WHERE id = $2',
['paid', order.rows[0].id]
);
await grantAccess(paystackResponse.data.customer.email);
res.redirect('/success');
});
The key differences from the vulnerable version:
- The expected amount comes from your database, not from the frontend or the webhook
- The paid amount comes from the Paystack API response, not from the frontend callback
- Both amount and currency are compared
- Mismatches are logged as potential security events
Server-Side Initialization: The Strongest Defense
Instead of initializing the transaction on the frontend with the amount in JavaScript, initialize it on your server using the Paystack /transaction/initialize endpoint:
// Server-side: initialize the transaction
app.post('/create-payment', async function(req, res) {
var orderId = req.body.orderId;
// Get the price from your database (not from the request body)
var order = await db.query(
'SELECT amount_kobo, currency FROM orders WHERE id = $1 AND user_id = $2',
[orderId, req.user.id]
);
if (!order.rows.length) {
return res.status(404).json({ error: 'Order not found' });
}
var https = require('https');
var postData = JSON.stringify({
email: req.user.email,
amount: order.rows[0].amount_kobo,
currency: order.rows[0].currency,
reference: 'order_' + orderId + '_' + Date.now(),
callback_url: 'https://yoursite.com/verify',
});
var options = {
hostname: 'api.paystack.co',
path: '/transaction/initialize',
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
};
var paystackReq = https.request(options, function(paystackRes) {
var data = '';
paystackRes.on('data', function(chunk) { data += chunk; });
paystackRes.on('end', function() {
var result = JSON.parse(data);
// Save the reference to your order
db.query(
'UPDATE orders SET payment_reference = $1 WHERE id = $2',
[result.data.reference, orderId]
);
// Return the authorization URL to the frontend
res.json({ authorizationUrl: result.data.authorization_url });
});
});
paystackReq.write(postData);
paystackReq.end();
});
With server-side initialization, the amount is set on your server using your database price. The frontend never sees the amount. The attacker cannot tamper with it because it never passes through the browser.
You still need server-side verification after payment (step 3 above), but server-side initialization removes the attack surface almost entirely.
Amount Validation in Webhook Handlers
The same amount validation must happen in your webhook handler, not just in your callback URL verification. Many integrations rely on webhooks as the primary payment confirmation:
async function handleChargeSuccess(webhookData) {
var reference = webhookData.reference;
// Get expected amount from your database
var order = await db.query(
'SELECT amount_kobo, currency FROM orders WHERE payment_reference = $1',
[reference]
);
if (!order.rows.length) {
console.error('No order found for reference: ' + reference);
return;
}
// Verify with Paystack API (do not trust webhook data alone)
var verified = await verifyTransaction(reference);
if (verified.data.amount !== order.rows[0].amount_kobo) {
console.error(
'Amount mismatch in webhook for ' + reference +
': expected ' + order.rows[0].amount_kobo +
', got ' + verified.data.amount
);
await logSecurityEvent('webhook_amount_mismatch', reference);
return;
}
// Amount matches. Safe to grant value.
await fulfillOrder(order.rows[0]);
}
Notice that the amount check uses the Paystack API response, not the webhook payload data. The webhook payload might be replayed or, in theory, could be out of date. The API response is the authoritative source.
Monitoring for Tampering Attempts
Log every amount mismatch as a security event. Set up alerts when mismatches occur. A single mismatch might be a bug. Multiple mismatches from the same IP or customer are an attack.
async function logSecurityEvent(type, reference, paidAmount, expectedAmount) {
await db.query(
'INSERT INTO security_events (event_type, reference, paid_amount, expected_amount, created_at) VALUES ($1, $2, $3, $4, NOW())',
[type, reference, paidAmount, expectedAmount]
);
// Alert if this is the third mismatch today
var count = await db.query(
'SELECT COUNT(*) FROM security_events WHERE event_type = $1 AND created_at > NOW() - INTERVAL '24 hours'',
['price_tampering']
);
if (parseInt(count.rows[0].count) >= 3) {
sendAlertToTeam('Multiple price tampering attempts detected in the last 24 hours');
}
}
Monitoring lets you identify attackers, block repeat offenders, and build evidence if you need to involve law enforcement.
Key Takeaways
- ✓Price tampering is the most exploited vulnerability in Paystack integrations. The attacker modifies the checkout amount using browser DevTools before the payment is processed.
- ✓Paystack processes whatever amount it receives. It has no way to know what the "correct" price should be for your product or service.
- ✓The defense is server-side amount validation: after the payment, verify the amount from the Paystack API response against your database price. If they do not match, do not grant value.
- ✓Never trust the amount from the frontend, the webhook payload, or the callback URL. Always verify through the Paystack /transaction/verify endpoint and compare against your authoritative price source.
- ✓Initialize transactions server-side using the Paystack /transaction/initialize endpoint for maximum security. The amount is set on your server and cannot be tampered with in the browser.
- ✓Price tampering also applies to currency. Verify the currency matches what you expect, not just the amount.
Frequently Asked Questions
- Can price tampering happen with Paystack Redirect too?
- Yes, if you initialize the redirect URL on the frontend with the amount in the request. The attacker can modify the request before it reaches Paystack. Server-side initialization via the /transaction/initialize endpoint prevents this for both Inline and Redirect flows.
- What if the attacker tampers with the amount in my database?
- If the attacker has access to your database, you have a much larger security problem than price tampering. Secure your database with proper authentication, encryption at rest, and access controls. The database is your authoritative price source, so its integrity is critical.
- Should I refund tampered transactions automatically?
- Not automatically. Log the mismatch, block the order, and investigate. The customer may have paid a small amount and you do not want to automatically refund it (keeping it prevents them from trying again immediately). Handle refunds manually after investigation.
- Does server-side initialization prevent all price tampering?
- It eliminates browser-based tampering because the amount never passes through the browser. But you still need server-side verification as defense in depth. A bug in your initialization code could set the wrong amount, and verification catches that.
- How common is price tampering in production?
- Very common. It is the easiest attack to execute (requires only browser DevTools) and the most rewarding (free products or services). Any Paystack integration that handles real money should assume price tampering will be attempted.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon