Bonaventure OgetoBy Bonaventure Ogeto|

Server Side Amount Validation: The Most Exploited Bug

After verifying a transaction with the Paystack API, compare the returned amount and currency against the expected values in your database. If the paid amount does not match the order total, reject the transaction and log it as a potential attack. This single check stops the most common exploit against Paystack integrations.

The Bug: What Missing Amount Validation Looks Like

Here is the verification code that appears in countless Paystack integration tutorials, blog posts, and production codebases:

app.get('/payment/verify', async function(req, res) {
  var reference = req.query.reference;

  var response = await verifyTransaction(reference);

  if (response.data.status === 'success') {
    // Grant access, ship product, activate subscription
    await activateOrder(reference);
    return res.redirect('/thank-you');
  }

  res.redirect('/payment-failed');
});

This code answers one question: "Did the customer pay?" But it does not answer the critical follow-up: "Did the customer pay the right amount?"

A transaction with status "success" means money moved. It does not mean the correct amount of money moved. A payment of 1 kobo (less than a penny) is still a "successful" transaction.

The Exploit: How Attackers Take Advantage

The attacker visits your checkout page. Using browser DevTools, they modify the amount parameter in the Paystack Inline JS call. Instead of paying the listed price, they pay the minimum possible amount.

The transaction succeeds on the Paystack side because Paystack processed a valid payment. Your verification endpoint checks that the status is "success" and it is. Your code grants value. The attacker gets a product or service worth thousands of naira for virtually nothing.

This exploit is so simple that it has been documented in YouTube tutorials and blog posts aimed at "finding free stuff online." Any Paystack integration without amount validation is a target.

The same exploit works through different vectors:

  • Inline JS modification: Change the amount in the PaystackPop.checkout() call
  • API request interception: If you initialize server-side but send the amount from the frontend, intercept and modify the API request
  • Callback URL manipulation: If you trust the amount from the callback URL query parameters instead of verifying with the API

The Fix: Database-Backed Amount Validation

The fix has three parts: store the expected amount, verify the paid amount, and compare them.

Part 1: Store the Expected Amount When Creating the Order

app.post('/checkout', async function(req, res) {
  var productId = req.body.productId;

  // Get the price from your database (not from the request)
  var product = await db.query(
    'SELECT price_kobo, currency FROM products WHERE id = $1',
    [productId]
  );

  if (!product.rows.length) {
    return res.status(404).json({ error: 'Product not found' });
  }

  var reference = 'order_' + Date.now() + '_' + Math.random().toString(36).substring(7);

  // Store the expected amount with the order
  await db.query(
    'INSERT INTO orders (reference, product_id, expected_amount, expected_currency, status) VALUES ($1, $2, $3, $4, $5)',
    [reference, productId, product.rows[0].price_kobo, product.rows[0].currency, 'pending']
  );

  res.json({
    reference: reference,
    amount: product.rows[0].price_kobo,
    publicKey: process.env.PAYSTACK_PUBLIC_KEY,
  });
});

Part 2: Verify and Compare After Payment

app.get('/payment/verify', async function(req, res) {
  var reference = req.query.reference;

  // Get the expected amount from your database
  var order = await db.query(
    'SELECT expected_amount, expected_currency, status FROM orders WHERE 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('/thank-you'); // Already processed
  }

  // Verify with Paystack
  var response = await verifyTransaction(reference);

  if (response.data.status !== 'success') {
    return res.redirect('/payment-failed');
  }

  // THE CRITICAL CHECK
  var paidAmount = response.data.amount;
  var paidCurrency = response.data.currency;
  var expectedAmount = order.rows[0].expected_amount;
  var expectedCurrency = order.rows[0].expected_currency;

  if (paidAmount !== expectedAmount || paidCurrency !== expectedCurrency) {
    console.error(
      'AMOUNT MISMATCH: ref=' + reference +
      ' paid=' + paidAmount + ' ' + paidCurrency +
      ' expected=' + expectedAmount + ' ' + expectedCurrency
    );

    await db.query(
      'UPDATE orders SET status = $1 WHERE reference = $2',
      ['amount_mismatch', reference]
    );

    return res.status(400).json({ error: 'Payment amount does not match order total' });
  }

  // All checks passed
  await db.query(
    'UPDATE orders SET status = $1, paid_at = NOW() WHERE reference = $2',
    ['paid', reference]
  );

  await grantAccess(reference);
  res.redirect('/thank-you');
});

Where to Get the Amount: Trust Hierarchy

Not all sources of amount information are equally trustworthy:

SourceTrust LevelUse For
Your databaseHighestExpected amount (authoritative price)
Paystack API (/transaction/verify)HighPaid amount (what was actually charged)
Webhook payloadMediumTrigger for verification, not authoritative
Callback URL parametersLowReference lookup only
Frontend request bodyNoneNever trust for pricing

The pattern is: get the expected amount from your database and the paid amount from the Paystack API. Compare the two. Do not use any other source for either value.

A common mistake is trusting the webhook payload for the amount. While webhook payloads are signed and legitimate, they should still trigger an API verification call for critical decisions like amount validation. This is defense in depth.

Edge Cases to Handle

Amount validation has several edge cases that trip up developers:

Partial Payments

If your business model allows partial payments or installments, your validation logic needs to account for this. Do not blindly reject amounts that are less than the full price if partial payment is legitimate.

var isFullPayment = paidAmount === expectedAmount;
var isValidPartialPayment = order.rows[0].allows_partial
  && paidAmount >= order.rows[0].minimum_payment;

if (!isFullPayment && !isValidPartialPayment) {
  // Reject: not a valid payment amount
}

Currency Conversion

If you accept multiple currencies, do not just compare numbers. A payment of 5000 in GHS (Ghana Cedis) is not the same as 5000 in NGN (Nigerian Naira). Always compare both amount and currency.

Discount Codes and Coupons

If you apply discounts, calculate the final price server-side and store it in the order. The expected amount should reflect the discounted price, not the original price.

var originalPrice = product.price_kobo;
var discount = calculateDiscount(couponCode, originalPrice);
var finalPrice = originalPrice - discount;

// Store the final price as the expected amount
await db.query(
  'INSERT INTO orders (reference, expected_amount, discount_applied) VALUES ($1, $2, $3)',
  [reference, finalPrice, discount]
);

Paystack Fees

If you pass charges to the customer (Paystack's "charge_bearer" option), the customer pays the order amount plus fees. But the amount returned by the Paystack API is the amount that was charged, not the amount you receive. Make sure your comparison accounts for this.

Amount Validation in Webhook Handlers

Your webhook handler needs the same amount validation as your callback URL handler. Many integrations use webhooks as the primary or sole confirmation mechanism:

async function handleWebhookChargeSuccess(data) {
  var reference = data.reference;

  // Always verify with the API, even in the webhook handler
  var verified = await verifyTransaction(reference);

  var order = await db.query(
    'SELECT expected_amount, expected_currency FROM orders WHERE reference = $1',
    [reference]
  );

  if (!order.rows.length) {
    console.error('Webhook for unknown order: ' + reference);
    return;
  }

  if (verified.data.amount !== order.rows[0].expected_amount) {
    console.error('Amount mismatch in webhook: ' + reference);
    return;
  }

  if (verified.data.currency !== order.rows[0].expected_currency) {
    console.error('Currency mismatch in webhook: ' + reference);
    return;
  }

  // Safe to fulfill
  await fulfillOrder(reference);
}

Both your callback handler and webhook handler should reach the same conclusion about whether to grant value. If they disagree (callback rejects but webhook grants), you have an inconsistency that attackers can exploit.

Logging and Monitoring Amount Mismatches

Every amount mismatch should be logged, tracked, and reviewed. A single mismatch might be a legitimate edge case. Repeated mismatches are an active attack.

Your security event log should capture:

  • Transaction reference
  • Expected amount and currency
  • Paid amount and currency
  • Customer email or identifier
  • Timestamp
  • IP address (from the callback request, if available)

Set up alerts for patterns like:

  • More than 3 mismatches in 24 hours (attack in progress)
  • Multiple mismatches from the same customer email (repeat attacker)
  • Mismatches where the paid amount is suspiciously small (1 kobo, 100 kobo)

For comprehensive logging patterns that avoid leaking secrets, see logging payments without logging secrets.

Key Takeaways

  • Missing amount validation is the most exploited bug in Paystack integrations across Africa. Attackers pay a tiny amount and receive full value because the server only checks the transaction status, not the amount.
  • Always compare the amount from the Paystack API response against the expected price in your database. Never trust the amount from the frontend, the webhook, or the callback URL parameters.
  • Check both amount and currency. An attacker could pay the correct number in a different, cheaper currency.
  • Use your database as the single source of truth for pricing. Store the expected amount when the order is created. Compare against it during verification.
  • Log amount mismatches as security events for monitoring and investigation.
  • Apply the same validation in both your callback URL handler and your webhook handler. Both entry points need the same protection.
  • Server-side transaction initialization via the Paystack API is the strongest preventive measure because it removes the amount from the browser entirely.

Frequently Asked Questions

Is this bug specific to Paystack?
No. Every payment gateway has this issue. Stripe, Flutterwave, and all others process whatever amount they receive. Amount validation is always the responsibility of the merchant application. The bug is in your code, not in the gateway.
What should I do with a tampered transaction?
Do not grant value. Log the mismatch as a security event. Keep the payment (do not auto-refund, as this allows repeated attempts). Investigate the customer account. If you see multiple tampering attempts, consider blocking the customer.
Can I validate the amount on the frontend instead?
No. Any validation on the frontend can be bypassed by the attacker. The attacker controls the browser. Server-side validation is the only reliable approach because the attacker does not control your server.
Does server-side initialization make amount validation unnecessary?
Server-side initialization is the strongest prevention because it removes the amount from the browser. But you should still validate the amount during verification as defense in depth. A bug in your initialization code could set the wrong amount.
How do I handle amount validation with subscription payments?
For subscriptions, store the expected plan amount in your database when the customer subscribes. On each renewal webhook, verify that the charged amount matches the plan amount. If Paystack changes the amount (due to plan changes or prorations), update your database first.

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