Skip to main content
Blog

Web

A "payment successful" page before the payment is confirmed: a business logic flaw

Showing "payment successful" as soon as the user returns from the payment provider is a mistake. Only the provider's callback confirms the status.

· 7 min read

Photo: Shixart1985, CC BY 2.0 · Wikimedia Commons

When people think of web security, they usually think of SQL injection or XSS. But expertise reports regularly include another category: business logic flaws. Automated scanners rarely find them, because the code works "correctly" from a technical point of view. The problem is in the logic: the system trusts something it should not.

One of the clearest examples is the payment flow. In this post we look at a case found during the expertise of our own product (a tax and finance SaaS platform) and how we closed it.

What the problem looks like

A typical payment flow:

  1. The user picks a plan and clicks "Pay".
  2. The site redirects to the payment provider's page.
  3. The user enters card details.
  4. The provider sends the user back to the site (return URL).
  5. The provider reports the payment result in a separate server-to-server request (callback or webhook).

The mistake happens at step 4. In many projects, opening the return URL, for example /payment/success?order=123, immediately shows "Payment successful", and sometimes even activates the subscription. But anyone can reach that address without paying: typing it into the address bar, reloading the page or pressing "back" on the provider's page is enough.

That is exactly what the experts showed in our case: the success page appeared before the provider had actually confirmed the payment. Even when no money was charged, the user saw "paid" in the interface.

Why it matters

  • Financial loss. If the success page also enables the service, the user gets paid features without paying.
  • Incorrect reporting. Accounting and the admin panel show orders as paid when they were not.
  • Trust. The user sees "paid", then the service is switched off, which means extra support requests.

OWASP places this kind of error under A04 "Insecure Design" in the Top 10 (2021). The closest CWE entry is CWE-840 (Business Logic Errors).

The core rule: anything that arrives through the user's browser is a hint, not a confirmation. Only the provider's signed server-to-server message changes the payment status.

The right architecture

1. Explicit order states

Create a separate record for every payment and give it an explicit status: pending, paid, failed, expired. A new order is always created as pending.

2. Only the callback changes the status

When the provider's callback arrives:

  • verify the signature or secret key (each provider has its own mechanism);
  • check that the amount and currency match the order;
  • if the order is already paid, do not process it again (idempotency);
  • only then move the status to paid and activate the service.

A simplified Laravel example:

public function callback(Request $request)
{
    abort_unless($this->provider->verifySignature($request), 403);

    $payment = Payment::where('external_id', $request->input('transaction_id'))
        ->lockForUpdate()->firstOrFail();

    if ($payment->status === 'paid') {
        return response()->json(['ok' => true]); // duplicate callback
    }

    abort_unless((int) $request->input('amount') === $payment->amount, 422);

    DB::transaction(function () use ($payment) {
        $payment->update(['status' => 'paid', 'paid_at' => now()]);
        $payment->subscription->activate();
    });

    return response()->json(['ok' => true]);
}

3. The return page only displays the status

When the user comes back, the page changes nothing. It reads the status from the database and shows a matching message:

  • paid: "Payment confirmed";
  • pending: "Payment is being verified", plus a small request that asks for the status again after a few seconds;
  • failed or expired: "Payment did not go through", plus a retry button.

The return page must also be visible only to the owner of the order. Otherwise, changing it to ?order=124 would show someone else's order, which is a separate vulnerability (IDOR).

4. Clean up stale pending payments

The callback may never arrive: the user closed the provider's page or the connection dropped. Such records should not stay pending forever. We added a scheduled task: payments not confirmed within a set time are moved to expired, and where needed the final status is requested through the provider's API.

// routes/console.php
Schedule::call(function () {
    Payment::where('status', 'pending')
        ->where('created_at', '<', now()->subHours(2))
        ->update(['status' => 'expired']);
})->hourly();

Choose the interval according to your provider's terms.

Checklist

Try the following on your own project:

  • If you open the return URL manually, does the service activate or does the page say "paid"?
  • Is the callback signature verified? Send a request without a signature; it must be rejected.
  • Is the amount in the callback compared with the order amount?
  • If the same callback arrives twice, is the subscription extended twice?
  • How many old pending records are there, and who cleans them up?
  • Does the return page show another user's order?

Other business logic flaws

Payments are the most common example, but not the only one. Our expertise report contained another business logic finding: some actions could be performed without checking the object's current state. For example, editing a record that was already closed, or skipping an approval step. The fix is the same: before every action, the server must check "is this action allowed in this state?". A button hidden in the interface is not protection.

Summary

Business logic flaws require reading the code and understanding the whole process. That is why we look for them not with an automated scanner but by walking through each flow together: the Claude AI agent quickly analyses code related to payments, registration and state changes, and the expert verifies every finding in practice and fixes it.

If you are preparing a project for the official expertise, request a security scan. We will help you find and close the gaps in payments and other key flows before the expertise.