Business Logic Flaws Code Review Guide
Table of Contents
Introduction
Business logic flaws are vulnerabilities that arise from faulty assumptions in application design rather than from technical coding mistakes. Unlike SQL injection or XSS, these flaws cannot be detected by automated scanners because they involve the legitimate features of an application being used in unintended ways.
A business logic vulnerability exists when an attacker can manipulate the normal workflow of an application to achieve an outcome the developers never intended—purchasing items at zero cost, bypassing approval steps, accumulating unlimited rewards, or escalating privileges through indirect state manipulation. These flaws are often the highest-impact findings in security assessments precisely because they slip through automated tooling.
Business Logic Flaw vs. Technical Vulnerability
' OR 1=1 --Exploits a coding mistake
Automated scanners can detect it
Well-documented fix patterns
Example: SQL injection, XSS
Apply coupon → change qty → checkoutExploits a design assumption
Scanners almost never find it
Requires understanding business context
Example: Price manipulation, workflow bypass
Typical Business Logic Attack Pattern
Why Scanners Miss These
Automated security scanners test for known technical vulnerability patterns (malformed input, missing headers, etc.). Business logic flaws exploit correct code that implements incorrect assumptions—a scanner has no way to know what the business rules should be. Only human code review and threat modeling can systematically find them.
Real-World Scenario
Consider an e-commerce checkout flow. The application calculates the order total on the client side and sends it to the server for payment processing. The developer assumed users would only interact through the UI:
Vulnerable Checkout API
1// Client-side (React)
2async function handleCheckout(cart) {
3 const total = cart.items.reduce(
4 (sum, item) => sum + item.price * item.quantity, 0
5 );
6
7 const response = await fetch('/api/checkout', {
8 method: 'POST',
9 body: JSON.stringify({
10 items: cart.items,
11 total: total, // Client-calculated total sent to server
12 }),
13 });
14}
15
16// Server-side (Express)
17app.post('/api/checkout', async (req, res) => {
18 const { items, total } = req.body;
19
20 // BUG: Server trusts the client-provided total!
21 const charge = await stripe.charges.create({
22 amount: Math.round(total * 100),
23 currency: 'usd',
24 source: req.body.paymentToken,
25 });
26
27 await createOrder(req.user.id, items, total);
28 res.json({ success: true, orderId: charge.id });
29});An attacker intercepts the request with a proxy and changes total from 299.99 to 0.01. The server charges one cent and creates a legitimate order for the full set of items. The code has no technical bug—no injection, no overflow—just a flawed assumption that the client can be trusted.
What is the root cause of this vulnerability?
Understanding Logic Flaws
Business logic flaws stem from implicit assumptions developers make about how users will interact with the application. These assumptions are rarely documented, making them invisible to anyone who wasn't part of the original design conversation.
Common Dangerous Assumptions
| Assumption | Reality | Exploit |
|---|---|---|
| Users follow the intended step order | Steps can be skipped via direct API calls | Skip payment step, go straight to order confirmation |
| Prices and totals come from the UI | Request bodies can be freely modified | Change price to 0.01 in the checkout request |
| Quantities are always positive integers | Negative values bypass validation | Add -1 items to get a credit/refund |
| A coupon can only be applied once | Race conditions allow parallel applies | Send 50 concurrent coupon-apply requests |
| Users can only act on their own resources | IDs in requests can be changed | Change user_id parameter to access other accounts |
| Rate limits prevent brute force | Limits may reset or apply only to one endpoint | Rotate between equivalent endpoints to bypass limits |
The OWASP Perspective
OWASP classifies business logic vulnerabilities under multiple categories including A04:2021 Insecure Design and A01:2021 Broken Access Control. They recommend threat modeling during design, not just testing after development, because these flaws are fundamentally design issues.
Why are business logic flaws considered harder to find than injection vulnerabilities?
Common Flaw Categories
While each business logic flaw is unique to the application, they tend to fall into recognizable categories. Knowing these categories helps you systematically audit code during review.
Business Logic Flaw Taxonomy
| Category | Description | Example |
|---|---|---|
| Price/Value Manipulation | Modifying monetary values the server trusts | Changing item price in checkout request |
| Workflow Bypass | Skipping mandatory steps in a process | Accessing order confirmation without paying |
| Abuse of Functionality | Using a legitimate feature in unintended ways | Using "gift card" feature to transfer money between accounts |
| Insufficient Process Validation | Missing server-side checks for state transitions | Changing order status from "pending" to "shipped" directly |
| Accumulation Abuse | Exploiting reward/loyalty systems beyond limits | Earning referral bonuses by referring yourself |
| Numeric Boundary Errors | Exploiting edge cases in numeric handling | Negative quantities causing refunds or integer overflow in totals |
| Time-of-Check to Time-of-Use | Exploiting gaps between validation and action | Modifying cart contents after coupon validation |
Category: Negative Quantity Exploit
1# Vulnerable order processing
2@app.route('/api/add-to-cart', methods=['POST'])
3def add_to_cart():
4 item_id = request.json['item_id']
5 quantity = request.json['quantity']
6
7 # No check for negative quantities!
8 item = Product.query.get(item_id)
9 cart = get_user_cart(current_user.id)
10
11 # With quantity = -5 and price = 100:
12 # line_total = -500 (credit instead of charge!)
13 line_total = item.price * quantity
14 cart.add_item(item_id, quantity, line_total)
15 cart.update_total()
16
17 return jsonify({'cart_total': cart.total})Category: Workflow Step Bypass
1// Vulnerable multi-step form (no server-side state tracking)
2// Step 1: Enter details
3app.post('/api/application/details', (req, res) => {
4 saveDetails(req.user.id, req.body);
5 res.json({ nextStep: '/verify' });
6});
7
8// Step 2: Identity verification (meant to be mandatory)
9app.post('/api/application/verify', (req, res) => {
10 verifyIdentity(req.user.id, req.body);
11 res.json({ nextStep: '/submit' });
12});
13
14// Step 3: Final submission
15app.post('/api/application/submit', (req, res) => {
16 // BUG: Does NOT check if step 2 (verification) was completed!
17 const app = finalizeApplication(req.user.id);
18 res.json({ applicationId: app.id, status: 'approved' });
19});
20
21// Attacker skips step 2 entirely by calling /submit directlyAn application allows users to transfer loyalty points to other users. What business logic risk should you check for?
Code Review Patterns
During code review, look for these specific code patterns that indicate potential business logic vulnerabilities. These are the "sinks" of business logic flaws—places where assumptions about data or flow can be violated.
Pattern 1: Client-Trusted Values
1// RED FLAG: Any time a monetary value, role, or status comes from
2// the request body rather than being computed server-side
3
4// VULNERABLE
5app.post('/api/order', (req, res) => {
6 const { price, discount, total } = req.body; // All from client!
7 chargeUser(req.user, total);
8});
9
10// SECURE
11app.post('/api/order', (req, res) => {
12 const { itemIds, couponCode } = req.body; // Only IDs from client
13 const items = Product.findAll(itemIds); // Prices from DB
14 const discount = validateCoupon(couponCode); // Discount computed server-side
15 const total = computeTotal(items, discount); // Total computed server-side
16 chargeUser(req.user, total);
17});Pattern 2: Missing State Machine Checks
1# RED FLAG: Status transitions without validating current state
2
3# VULNERABLE: Any status can be set regardless of current state
4@app.route('/api/order/<order_id>/status', methods=['PUT'])
5def update_status(order_id):
6 new_status = request.json['status']
7 order = Order.query.get(order_id)
8 order.status = new_status # No transition validation!
9 db.session.commit()
10 return jsonify({'status': new_status})
11
12# SECURE: Enforce valid state transitions
13VALID_TRANSITIONS = {
14 'pending': ['confirmed', 'cancelled'],
15 'confirmed': ['processing', 'cancelled'],
16 'processing': ['shipped', 'cancelled'],
17 'shipped': ['delivered', 'returned'],
18 'delivered': ['returned'],
19 'cancelled': [],
20 'returned': [],
21}
22
23@app.route('/api/order/<order_id>/status', methods=['PUT'])
24def update_status(order_id):
25 new_status = request.json['status']
26 order = Order.query.get(order_id)
27
28 if new_status not in VALID_TRANSITIONS.get(order.status, []):
29 return jsonify({'error': 'Invalid status transition'}), 400
30
31 order.status = new_status
32 db.session.commit()
33 return jsonify({'status': new_status})Pattern 3: Missing Idempotency / Replay Protection
1// RED FLAG: Sensitive actions that can be repeated without limits
2
3// VULNERABLE: Referral bonus applied every time the endpoint is called
4app.post('/api/referral/claim', async (req, res) => {
5 const { referralCode } = req.body;
6 const referrer = await User.findOne({ referralCode });
7 if (referrer) {
8 await referrer.addBonus(10); // No check if already claimed!
9 await req.user.addBonus(5);
10 res.json({ success: true });
11 }
12});
13
14// SECURE: Track and prevent duplicate claims
15app.post('/api/referral/claim', async (req, res) => {
16 const { referralCode } = req.body;
17
18 // Check if this user already claimed a referral
19 const existing = await ReferralClaim.findOne({
20 claimedBy: req.user.id,
21 });
22 if (existing) {
23 return res.status(409).json({ error: 'Referral already claimed' });
24 }
25
26 const referrer = await User.findOne({ referralCode });
27 if (!referrer || referrer.id === req.user.id) {
28 return res.status(400).json({ error: 'Invalid referral' });
29 }
30
31 // Atomic: record claim and add bonuses in one transaction
32 await db.transaction(async (tx) => {
33 await ReferralClaim.create({ referrer: referrer.id, claimedBy: req.user.id }, { transaction: tx });
34 await referrer.addBonus(10, { transaction: tx });
35 await req.user.addBonus(5, { transaction: tx });
36 });
37
38 res.json({ success: true });
39});What to Search For
During code review, search for these keywords and patterns:req.body.price, req.body.total, req.body.amount — client-supplied monetary valuesreq.body.role, req.body.status, req.body.isAdmin — client-supplied privilege datareq.body.discount, req.body.quantity — values that should be validated against bounds
Missing checks for quantity > 0, amount > 0, or enum membership on status fields
Assumption-Based Input Flaws
Many business logic flaws hide in the gap between what the UI allows and what the API accepts. The UI might enforce rules (positive quantities, valid date ranges, minimum order amounts), but the API may not re-validate those constraints.
Numeric Edge Cases
1// Discount percentage from request — what if it's over 100?
2app.post('/api/apply-discount', (req, res) => {
3 const { discountPercent } = req.body;
4
5 // VULNERABLE: No upper bound check
6 const discount = cartTotal * (discountPercent / 100);
7 const finalTotal = cartTotal - discount;
8 // discountPercent = 150 → finalTotal is NEGATIVE → refund!
9});
10
11// Integer overflow in quantity × price
12app.post('/api/cart/add', (req, res) => {
13 const { quantity, itemId } = req.body;
14 const item = getItem(itemId);
15
16 // VULNERABLE: Very large quantity could cause overflow
17 // In some languages/systems, MAX_INT + 1 wraps to negative
18 const lineTotal = item.price * quantity;
19});
20
21// SECURE: Validate all numeric boundaries
22app.post('/api/apply-discount', (req, res) => {
23 const { discountPercent } = req.body;
24
25 if (typeof discountPercent !== 'number' ||
26 discountPercent < 0 ||
27 discountPercent > 100) {
28 return res.status(400).json({ error: 'Invalid discount' });
29 }
30
31 const discount = cartTotal * (discountPercent / 100);
32 const finalTotal = Math.max(0, cartTotal - discount);
33});Type Juggling / Coercion Exploits
1# VULNERABLE: Loose comparison allows bypass
2def check_access_code(user_input, stored_code):
3 # In PHP (==): "0e123" == "0e456" is True (both parsed as 0)
4 # In JavaScript (==): 0 == "" is True
5 # In Python, explicit type coercion can cause issues:
6 if int(user_input) == stored_code:
7 return True # "00123" and 123 both pass
8
9# SECURE: Strict comparison with type checking
10def check_access_code(user_input, stored_code):
11 if not isinstance(user_input, str) or not isinstance(stored_code, str):
12 return False
13 # Constant-time comparison to prevent timing attacks
14 return hmac.compare_digest(user_input, stored_code)Common Input Assumption Failures
| Input Field | Assumption | Exploit |
|---|---|---|
| Quantity | Always ≥ 1 | Send 0, -1, or 999999999 |
| Price/Amount | Comes from product DB | Override in request body |
| Discount % | Between 0–100 | Send 200 for a net credit |
| Date range | Start < End | Swap dates to bypass duration checks |
| One account per email | Use aliases: user+tag@domain.com | |
| Currency | Matches store region | Switch to weaker currency after pricing |
A checkout API accepts a 'currency' field from the client. What business logic risk does this create?
Prevention Techniques
Preventing business logic flaws requires a different mindset than preventing technical vulnerabilities. The focus shifts from "sanitize input" to "enforce business rules server-side at every step."
Prevention Strategies
| Strategy | Purpose | Implementation |
|---|---|---|
| Server-side recalculation | Never trust client-supplied values | Recompute prices, totals, and discounts from DB data |
| State machine enforcement | Prevent step-skipping and invalid transitions | Track workflow state; validate transitions explicitly |
| Idempotency tokens | Prevent duplicate action abuse | Require unique token per sensitive operation |
| Boundary validation | Reject unexpected numeric ranges | Enforce min/max on quantities, amounts, percentages |
| Threat modeling | Identify flaws before code is written | Map data flows and trust boundaries in design phase |
| Invariant assertions | Catch impossible states at runtime | Assert total > 0, quantity > 0, balance >= 0 before writes |
Comprehensive Checkout Protection
1app.post('/api/checkout', async (req, res) => {
2 const { itemIds, quantities, couponCode, idempotencyKey } = req.body;
3
4 // 1. Idempotency: prevent duplicate submissions
5 const existing = await Order.findOne({ idempotencyKey, userId: req.user.id });
6 if (existing) {
7 return res.json({ orderId: existing.id, message: 'Already processed' });
8 }
9
10 // 2. Fetch authoritative prices from DB (never from client)
11 const items = await Product.findAll({ where: { id: itemIds } });
12 if (items.length !== itemIds.length) {
13 return res.status(400).json({ error: 'Invalid items' });
14 }
15
16 // 3. Validate quantities (positive integers within stock limits)
17 for (let i = 0; i < items.length; i++) {
18 const qty = quantities[i];
19 if (!Number.isInteger(qty) || qty < 1 || qty > items[i].stock) {
20 return res.status(400).json({ error: 'Invalid quantity' });
21 }
22 }
23
24 // 4. Server-side total calculation
25 let subtotal = items.reduce(
26 (sum, item, i) => sum + item.price * quantities[i], 0
27 );
28
29 // 5. Validate and apply coupon server-side
30 let discount = 0;
31 if (couponCode) {
32 const coupon = await Coupon.findOne({
33 where: { code: couponCode, active: true },
34 });
35 if (!coupon || coupon.usedCount >= coupon.maxUses) {
36 return res.status(400).json({ error: 'Invalid coupon' });
37 }
38 discount = Math.min(
39 subtotal * (coupon.percent / 100),
40 coupon.maxDiscount
41 );
42 }
43
44 const total = Math.max(0, subtotal - discount);
45
46 // 6. Invariant assertion
47 if (total <= 0 && subtotal > 0) {
48 throw new Error('Business rule violation: total cannot be zero for non-empty order');
49 }
50
51 // 7. Atomic order creation
52 const order = await db.transaction(async (tx) => {
53 const ord = await Order.create({
54 userId: req.user.id,
55 total,
56 idempotencyKey,
57 status: 'pending',
58 }, { transaction: tx });
59
60 if (couponCode) {
61 await Coupon.increment('usedCount', {
62 where: { code: couponCode },
63 transaction: tx,
64 });
65 }
66
67 for (let i = 0; i < items.length; i++) {
68 await Product.decrement('stock', {
69 by: quantities[i],
70 where: { id: items[i].id, stock: { [Op.gte]: quantities[i] } },
71 transaction: tx,
72 });
73 }
74
75 return ord;
76 });
77
78 res.json({ orderId: order.id, total });
79});Code Review Checklist
For every API endpoint, ask:
1. Does the server recalculate all monetary values from authoritative data?
2. Are workflow steps enforced server-side with state tracking?
3. Are quantities and amounts validated for type, range, and sign?
4. Can this action be replayed for unintended benefit?
5. Are there race conditions between validation and execution?
6. Can a user act on resources that don't belong to them?