Picture a shop that grants a discount once a basket reaches a minimum value. A customer adds enough items to qualify, applies the discount, then removes the items before checkout. If the order keeps the discount without checking eligibility again, every request can be perfectly well formed and authenticated. The final price is still wrong.
That is the shape of a business logic flaw. The application makes an assumption about the order in which people will use a feature, or about a value that cannot change later. A user takes a different path. The server accepts it because each step looks reasonable in isolation.
The basket is an illustrative example described by PortSwigger’s Web Security Academy, not a claim about a Vesperis client. The useful question is whether your own application rechecks a rule at the point where the consequence becomes final.
Why a scanner misses the point
A scanner can recognise a familiar error or probe an input for a known payload. It cannot infer that a customer should receive only one welcome credit, that an approver must be different from the requester, or that a refund cannot exceed the amount actually paid. Those rules belong to the product. To test them, someone has to learn the intended workflow and then try other sequences and states.
OWASP’s Web Security Testing Guide treats business logic as its own testing area for exactly this reason. The goal is to check whether the application enforces its own rules when a user changes values, repeats steps, skips steps or uses features in an unexpected order.
Five places the rules tend to break
1. A value moves from the browser to the source of truth
Prices, quantities, credit amounts and account roles are often visible in a request because the interface needs to display them. That does not make them safe for the server to trust. The server should calculate the payable amount from its own catalogue, eligibility and tax rules at checkout. The same principle applies to any sensitive decision: derive it from trusted state, rather than accepting the value the client happened to send.
2. A check happens too early
The basket example fails because the discount was valid when applied, but was not valid when the order was placed. Similar gaps appear when a reservation is checked before payment, an upload is approved before its contents change, or a permission is checked before a long-running job executes. A rule that protects a final action must be checked against the state that exists at that action.
3. The workflow assumes one route through it
Applications often present steps in a tidy order: create, review, approve, complete. The API may still accept a completion request without a completed review, or allow a user to repeat a step that was intended to happen once. We map each state transition and ask whether the server, rather than the interface, prevents invalid jumps and repetitions.
4. Separate features combine in an unexpected way
A voucher, loyalty balance and referral credit may each work correctly on its own. The error appears when two features share a mutable total, or one recalculates an order after the other has locked in an earlier value. These bugs cross team and service boundaries, which is why a feature-by-feature test can miss them.
5. Two valid requests arrive together
A single request may correctly check that a credit has not been used, then mark it used. Two requests can both pass the check before either update commits. The result is a race condition: the rule exists, but it is not enforced atomically. PortSwigger’s race condition research shows why testing only one request at a time leaves this class of flaw invisible.
Three example flows, step by step
These are fictional examples. Each shows a rule, the path the product expects, and the path that breaks the rule if the server does not enforce it. The Python-like snippets are illustrative; database and framework details will differ in your application.
1. The discount that survives a changed basket
Rule: A 10% discount applies only when the final basket is at least £100.
Expected Basket £120 → apply discount → checkout still £120 → pay £108.
Broken Basket £120 → apply discount → remove a £40 item → checkout £80 → pay £72.
Where to enforce it: Recheck the final basket and calculate the payable amount on the server when the order is placed. The £80 order should not receive the discount.
# Broken: reuse a discount saved before the basket changed.
total = cart.subtotal - cart.saved_discount
# Better: use server-side prices and recheck the final basket.
subtotal = sum(catalogue[item.sku].price * item.quantity
for item in cart.items)
discount = subtotal * Decimal("0.10") if subtotal >= Decimal("100") else 0
total = subtotal - discount
2. The approval step skipped through the API
Rule: An expense must be approved by someone other than its requester before payment.
Expected Alice submits £500 → Bob reviews and approves → payment is released.
Broken Alice submits £500 → calls the completion action directly → payment is released without Bob’s approval.
Where to enforce it: At completion, require an approved state and verify the approver is different from the requester. Hiding the button in the interface does not protect the API action.
# Broken: the completion endpoint trusts the UI's step order.
def complete_expense(expense):
release_payment(expense)
# Better: enforce the transition on the server.
def complete_expense(expense):
if expense.status != "approved":
raise InvalidState()
if expense.approved_by == expense.requested_by:
raise PermissionError("Independent approval required")
release_payment_once(expense)
3. A one-time credit used twice
Rule: Each account can redeem its £10 welcome credit once.
Expected Checkout A claims the credit → the claim is recorded → checkout B sees it is already used.
Broken Checkouts A and B both read “unused” → each deducts £10 → both orders complete.
Where to enforce it: Claim the credit and record its use as one atomic operation, so only one checkout can succeed.
# Broken: both requests can read "unused" before either writes.
if credit.used_at is None:
order.apply_credit(10)
credit.used_at = now()
# Better: claim the credit inside the order transaction.
with db.transaction():
claimed = db.execute(
"UPDATE credits SET used_at = NOW() "
"WHERE account_id = ? AND used_at IS NULL", [account_id]
).rowcount
if claimed != 1:
raise AlreadyRedeemed()
order.apply_credit(10)
order.save()
How we test the workflow
- Write down the rule. For each sensitive action, we ask what must always be true: who may act, which state the record must be in, how much value may move, and whether the action may happen more than once.
- Observe the normal path. We complete the workflow through the interface and record the requests and state changes. This gives us a baseline without guessing how the product should behave.
- Change one condition at a time. We alter client-controlled values, repeat a step, go backwards, skip forwards, and try the same action through another endpoint or account.
- Test timing where value is at stake. For one-time credits, stock limits, invitations and approvals, we check whether concurrent actions can pass a read-before-write guard. Testing uses controlled accounts and amounts agreed in scope.
- Prove the final state. A surprising response is not enough. We check the resulting order, balance, permission or record and document the rule that was violated.
The finding should explain the intended rule, the sequence that bypassed it, the state that proves the impact, and the smallest place to enforce the rule. That gives the product team a test case they can keep.
What to check in your application this week
- List the actions that move money, credit, inventory, permissions or private data. State each action’s invariant in a sentence.
- Recalculate sensitive totals on the server immediately before committing an order or transfer.
- Check that every state transition is enforced by the server, including direct API calls that bypass the interface.
- Make one-time actions idempotent and enforce their limits in the transaction or database constraint that records the outcome.
- Test combinations of discounts, credits and account roles, not just each feature alone.
- Add tests for retries and concurrent requests on operations where a double spend or duplicate approval matters.
Business logic testing starts with understanding what your application promises users and staff. The security question is whether those promises still hold when someone uses the same features in a different order, with different values, or at the same time.
References
- OWASP — Web Security Testing Guide: Business Logic Testing
- OWASP — Business Logic Security Cheat Sheet
- PortSwigger — Business logic vulnerabilities and examples
- PortSwigger — Race conditions