Web Application Security · advanced · ~11 min

Business logic flaws and race conditions

**What you will learn** - Recognize business logic flaws — vulnerabilities that abuse legitimate features rather than injecting a payload — and explain why automated scanners miss them. - Identify mass-assignment bugs where the server trusts client-supplied fields such as `role`, `price`, or `balance`. - Explain the TOCTOU (time-of-check to time-of-use) pattern and how concurrent requests exploit the gap between a check and the action that follows. - Remediate these flaws with server-side invariants, atomic critical sections (transactions, row locks, conditional updates), and idempotency keys — and verify each fix rejects abuse while accepting honest use. - Design detection and logging that surfaces logic abuse and race attempts without recording secrets.

Overview

Security objective. The asset you are protecting is application state and value: account balances, coupon budgets, inventory counts, order totals, and privilege levels. The threat is an authorized-looking user who abuses normal features — never sending a malicious string, just legitimate requests in an illegitimate order, with illegitimate values, or many at once. Your job as a defender is to detect and prevent state that violates the business rules the designer assumed but never enforced.

Most of the bugs in this course have a payload: a ' for SQL injection, a <script> for XSS, a ../ for path traversal. Business logic flaws and race conditions are different. The application does exactly what it was told to do — it just does something the designer never intended. There is nothing for a signature-based scanner to match.

This lesson builds directly on your prerequisite, Broken access control and IDOR. IDOR taught you that the server must re-check authorization on every object reference instead of trusting the client to only request its own data. Logic and race flaws generalize that lesson: the server must re-check and enforce every business invariant on the server side — order of steps, valid values, resource limits, one-time-use rules — and it must do so atomically so two requests cannot both pass the same check. The same trust boundary you learned to defend for object IDs must now defend workflow, pricing, and concurrency.

There is also a bridge to systems programming. TOCTOU is not a web-only idea — it is the same race you meet in low-level C when a program checks a file with access() and then opens it, and an attacker swaps the file in between. The web version splits a database check from a database write; the C version splits a filesystem check from a filesystem use. Same shape, different layer.

Why it matters

In authorized professional testing, logic and race bugs are often the highest-impact findings on the whole engagement, precisely because tools cannot find them. A scanner will happily report a missing security header while walking straight past a checkout flow that lets a customer set their own price, or a coupon endpoint that grants unlimited credit when hit with 50 parallel requests.

The business consequences are concrete:

  • Direct financial loss. Negative-quantity refunds, price tampering, and coupon/gift-card races have drained real revenue. A single-use $10 coupon redeemed 200 times in one burst is a $2,000 loss from one account.
  • Privilege escalation. Mass assignment that trusts a client role field can turn any registered user into an administrator.
  • Inventory and fairness abuse. Overselling limited stock or claiming more than one slot in a limited promotion.

Because these require understanding intent and concurrency, they are where a skilled human tester adds value that no automated pipeline can. And on the defensive side, the fixes — server-authoritative state, atomic transactions, idempotency — are foundational engineering habits that prevent whole classes of bugs, not just security issues. A checkout that cannot be raced is also a checkout that behaves correctly under legitimate high load.

Core concepts

1. Business logic flaw

Definition. A vulnerability where legitimate application features are used in an unintended sequence, with unintended values, or beyond intended limits — with no malformed input required.

Plain explanation. The code is bug-free in the compiler's sense. Every request is well-formed. The flaw is that the rules the designer assumed were never written down as enforced checks. "Obviously you pay before you get the goods" is an assumption; if the server does not verify payment before fulfillment, that assumption is a hole.

How it works. The attacker maps the intended workflow, then deviates: skips a step, repeats a step, submits a value the UI would never send (negative quantity, a price field, role=admin), or races a limit. Each individual request looks normal in the logs.

When it applies / when not. Applies wherever the server relies on the client to "do the right thing" — multi-step checkouts, wizards, quotas, pricing, role assignment. Does not apply where every rule is re-derived and re-enforced server-side from trusted state.

Pitfall. Assuming the UI is the security boundary. The UI only suggests valid input; an attacker uses the API directly and ignores the UI entirely.

2. Mass assignment

Definition. A bug where the server binds client-supplied request fields directly onto an internal record, including fields the client should never control.

Plain explanation. A framework helper like user.update(request.body) is convenient — it copies every submitted field. If the request body contains role, is_admin, credit_balance, or price, those get written too, even though the form was only "meant" to change a display name.

How it works. The attacker adds extra JSON fields the form never shows: {"name":"Al","role":"admin"}. The server dutifully assigns all of them.

When it applies / when not. Applies to any endpoint that spreads or auto-binds request bodies. Does not apply when the server explicitly allow-lists which fields each endpoint may write.

Pitfall. Relying on a deny-list ("strip out role") — you will forget a field. Allow-list the fields you accept instead.

3. TOCTOU race condition

Definition. Time-Of-Check to Time-Of-Use: a check and the action that depends on it are two separate, non-atomic steps, so concurrent execution can act on a condition that was true at check time but false at use time.

Plain explanation. "Is the coupon unused? Yes. → Mark it used and grant credit." If two requests both run the check before either runs the update, both see "unused" and both grant credit.

How it works. The attacker sends many parallel requests in a tight window (sometimes called single-packet or burst attacks) so several land in the gap between check and use. The narrower the window, the more requests you need — but web frameworks under load leave surprisingly wide windows.

When it applies / when not. Applies to any "read state → decide → write state" sequence on a shared resource without atomicity: coupons, balances, stock, one-time tokens, per-user limits. Does not apply when the decision and write are a single atomic database operation or are serialized by a lock.

Pitfall. Believing a fast server is safe. Speed shrinks the window but never closes it; only atomicity closes it.

Threat model

TRUST BOUNDARY: the network edge. Everything left of the API is attacker-controlled.

  UNTRUSTED                         |  TRUSTED (server-authoritative)
  ---------------------------------- | ----------------------------------
  [ Browser / API client ]           |
    | request body, headers,         |   ENTRY POINTS (must enforce rules):
    | field names, ORDER of steps,   |   - workflow: step N requires step N-1 done
    | and TIMING of requests         |   - values: price/role/qty derived server-side
    | (attacker fully controls all)  |   - limits: one-time-use, per-user quota
    v                               |   - concurrency: check+act must be ATOMIC
  === TRUST BOUNDARY (edge) ========>|
                                     |        v
                                     |   [ App logic ]--check--> [ Database ]
                                     |        ^   (gap here = TOCTOU race)  |
                                     |        +------------ act ------------+

ASSETS behind the boundary: balances, coupon budget, inventory, roles, order totals.
GOAL: no sequence, value, or burst of legitimate-looking requests can violate an invariant.

Knowledge check.

  1. What asset is protected when you make a coupon redemption atomic? (The coupon budget / the credit it grants — protected from being spent more than once.)
  2. Where is the trust boundary in the diagram, and which parts of a request does the attacker control across it? (The network edge; the attacker controls field names, values, the order of steps, and the timing/parallelism of requests.)
  3. What insecure assumption causes a mass-assignment bug? (That the client will only send the fields the form displays — so the server binds the whole body.)
  4. Which log signal hints at a race attempt rather than normal use? (A burst of near-simultaneous requests to the same limited resource from one account/session/token within milliseconds.)
  5. Why must you only test these against systems you own or are authorized to test? (Racing coupons or tampering prices on a live third-party system is fraud and unauthorized access — a crime — regardless of intent; labs must be isolated.)

Syntax notes

There is no single "attack payload" here — the essence is structure. Below are the defensive building blocks you will use, annotated. These are lab-safe patterns, not exploits.

Atomic conditional update (closes the TOCTOU window in SQL). The check lives inside the write, so the database — not your app code — enforces one-time use:

-- Only redeems if still unredeemed; returns affected-row count.
UPDATE coupons
   SET redeemed_by = :user_id,          -- act
       redeemed_at = NOW()
 WHERE code = :code
   AND redeemed_by IS NULL;             -- check, atomic with the act
-- If affected rows = 1 -> this request won the redemption.
-- If affected rows = 0 -> already redeemed; reject.

Allow-list binding (kills mass assignment). Never spread the whole body:

WRONG:   user.update(request.body)          # binds role, balance, anything
RIGHT:   name = request.body["name"]        # take only what this endpoint may change
         user.update(name=name)             # role/balance are set by trusted code only

Idempotency key (safe retries, no double effect). Client sends a unique key per logical action; the server records it and refuses a second execution:

POST /checkout
Idempotency-Key: 7f3c...-once-per-purchase
-> server stores key on first success; a replay with the same key returns the first result,
   it does not charge again.

Lesson

Not every vulnerability is a payload. Some of the highest-impact bugs occur when the app does exactly what it was told to do, but not what the designer intended.

Business logic flaws

These abuse legitimate features through unintended sequences or values:

  • Applying a discount code multiple times, or ordering a negative quantity to trigger a refund.
  • Skipping a step in a multi-stage flow (jumping straight to "confirm purchase" without "pay").
  • Manipulating a price or currency the client should not control.
  • Promoting yourself by sending a role field that the server trusts. This is called mass assignment: the server blindly copies client-supplied fields into a record.

Finding these requires understanding the application's intent. That is why automated scanners miss them and skilled humans find them.

Race conditions (TOCTOU)

TOCTOU stands for "time-of-check to time-of-use." The app checks a condition in one step, then acts on it in another. Concurrent requests can slip into the gap between the two.

For example, an attacker might:

  • Redeem a single-use coupon, withdraw an "available" balance, or use a one-time token many times at once, before the state has a chance to update.

The technique is to send many parallel requests in a tight window, exploiting the check-then-act gap. This is the web sibling of the TOCTOU file-race bugs in the secure-coding C exercises.

Fixes

  • Enforce invariants and state transitions server-side. Never trust client-supplied prices, roles, or steps.
  • For races, make the critical section atomic so check and act cannot be split. Use database transactions with proper locking, idempotency keys, or atomic decrements.

Code examples

The example is a coupon-redemption endpoint. We show the insecure version, the secure fix, and checks that prove the fix rejects abuse and accepts honest use. Pseudocode/SQL is used so the logic is language-neutral; the SQL is standard and runs on PostgreSQL or MySQL.

(1) Insecure version

WARNING: intentionally vulnerable — use only in a local, isolated, authorized lab. Do not deploy.
# INSECURE coupon redemption. Two independent steps: CHECK then ACT.
# Also mass-assigns the request body. Lab only.
def redeem(request, db):
    code = request.body["code"]
    row = db.query("SELECT redeemed_by FROM coupons WHERE code = %s", code)  # CHECK (time-of-check)
    if row and row.redeemed_by is not None:
        return {"error": "already redeemed"}, 409

    # ... unrelated work happens here: the TOCTOU window. Concurrent requests
    # all passed the CHECK above before any of them reaches the ACT below.

    db.execute("UPDATE coupons SET redeemed_by = %s WHERE code = %s",
               request.user.id, code)                                        # ACT (time-of-use)
    user = db.get_user(request.user.id)
    user.update(request.body)   # MASS ASSIGNMENT: body may carry role/credit
    grant_credit(request.user.id, coupon_value(code))
    return {"ok": True}, 200

Two defects: the CHECK and ACT are separate (raceable), and user.update(request.body) writes whatever the client sent.

(2) Secure version

# SECURE: one atomic conditional write is the check; strict allow-list; server-owned value.
def redeem(request, db):
    code = request.body["code"]
    if not is_valid_code_format(code):
        return {"error": "bad code"}, 400

    with db.transaction():                       # single atomic critical section
        affected = db.execute(
            "UPDATE coupons SET redeemed_by = %s, redeemed_at = NOW() "
            "WHERE code = %s AND redeemed_by IS NULL",   # check IS the write
            request.user.id, code)
        if affected != 1:                        # 0 -> someone already won it
            log_security("coupon.redeem.denied", request, code, result="already_redeemed")
            return {"error": "already redeemed"}, 409

        value = coupon_value_from_db(db, code)   # value comes from TRUSTED state, not the body
        grant_credit(db, request.user.id, value)

    # allow-list: this endpoint changes NOTHING on the user record except via trusted code above
    log_security("coupon.redeem.ok", request, code, result="granted")
    return {"ok": True}, 200

(3) Verify the fix — reject bad, accept good

# Lab verification. Run against your local instance only.
def test_single_redemption_accepted(db):
    seed_coupon(db, code="LAB10", value=10, redeemed_by=None)
    r = redeem(make_request(user=1, body={"code": "LAB10"}), db)
    assert r[1] == 200
    assert credit_of(db, user=1) == 10          # honest use ACCEPTED

def test_concurrent_redemption_rejected(db):
    seed_coupon(db, code="LAB10", value=10, redeemed_by=None)
    # fire 50 parallel redemptions of the SAME coupon by the same user
    results = run_parallel(lambda: redeem(make_request(user=1, body={"code": "LAB10"}), db),
                           times=50)
    ok = sum(1 for status in results if status == 200)
    assert ok == 1                              # race ABUSE REJECTED: exactly one wins
    assert credit_of(db, user=1) == 10          # credited exactly once, not 50x

def test_mass_assignment_rejected(db):
    seed_coupon(db, code="LAB10", value=10, redeemed_by=None)
    redeem(make_request(user=1, body={"code": "LAB10", "role": "admin"}), db)
    assert role_of(db, user=1) == "user"        # injected field IGNORED

Expected result. test_single_redemption_accepted passes (good input accepted). test_concurrent_redemption_rejected shows exactly one of 50 parallel requests succeeds and the balance rises by 10 once — the atomic WHERE redeemed_by IS NULL lets only one update affect a row. test_mass_assignment_rejected shows the smuggled role field never reaches the user record because the endpoint only writes trusted, server-derived state.

Line by line

Walking the secure redeem function:

  1. code = request.body["code"] — read only the one field this endpoint needs. We do not bind the whole body anywhere.
  2. is_valid_code_format(code) — cheap input validation. Rejects garbage early with 400, but note this is not the security check; format validity does not mean the coupon is unused.
  3. with db.transaction(): — opens the atomic critical section. Everything inside either fully commits or fully rolls back; concurrent transactions are serialized by the database on the affected row.
  4. The UPDATE ... WHERE code = %s AND redeemed_by IS NULL — the crucial line. The check (redeemed_by IS NULL) and the act (setting redeemed_by) are one statement the database performs atomically while holding a row lock. There is no gap for a second request to slip into.
  5. if affected != 1 — the database tells us how many rows changed. Exactly one means this request claimed the coupon. Zero means another request already claimed it; we reject with 409 and log a denied attempt.
  6. value = coupon_value_from_db(...) — the credit amount comes from trusted server state, never from the request body. A client cannot inflate its own credit.
  7. grant_credit(...) runs inside the same transaction, so credit and redemption commit together — you can never grant credit without also marking the coupon used.
  8. There is deliberately no user.update(request.body). The endpoint's contract is "redeem a coupon"; it writes nothing else, so no smuggled field can take effect.

Trace: 3 parallel requests for the same unused coupon LAB10.

Step Request A Request B Request C Coupon.redeemed_by
t0 UPDATE...WHERE NULL (waits for row lock) (waits for row lock) NULL
t1 affected=1, grants 10 blocked blocked 1 (A)
t2 commit UPDATE runs: redeemed_by no longer NULL blocked 1 (A)
t3 done: 200 affected=0 -> 409 UPDATE runs, affected=0 -> 409 1 (A)

Only A succeeds; B and C see zero affected rows and are cleanly rejected. Contrast the insecure version, where all three passed the standalone SELECT check at t0 and all three later granted credit.

Common mistakes

Mistake 1 — Trusting the UI as the boundary.

  • Wrong: "The dropdown only offers quantities 1–10, so quantity is safe."
  • Why wrong: The attacker calls the API directly and sends quantity: -5 or quantity: 999999. The UI never runs.
  • Corrected: Validate and enforce every constraint server-side (quantity is an integer in [1, max_stock]); reject out-of-range values with 400.
  • Recognize/prevent: Test every endpoint with a raw client, ignoring the front end. Any rule not enforced server-side does not exist.

Mistake 2 — Deny-listing fields instead of allow-listing.

  • Wrong: body.delete("role"); user.update(body) to "strip" dangerous fields.
  • Why wrong: You will miss one (is_admin, credit, verified, a new field added next sprint). Deny-lists rot.
  • Corrected: Bind only named, expected fields: user.update(name=body["name"]). Privileged fields are set exclusively by trusted server code.
  • Recognize/prevent: Grep for update(request.body), Object.assign(record, body), or spread of the whole body onto a model.

Mistake 3 — Fixing a race by checking harder before writing.

  • Wrong: if used: reject; sleep; if used again: reject; update(...) — adding more pre-checks.
  • Why wrong: Every check is still separate from the write. The window shrinks but persists; two requests can still both pass all checks.
  • Corrected: Make the decision and the write a single atomic operation (conditional UPDATE ... WHERE unused, SELECT ... FOR UPDATE, unique constraint, or atomic decrement).
  • Recognize/prevent: If you can point to a line that reads state and a different line that writes it, and nothing serializes them, it is racy.

Mistake 4 — Enforcing step order only with a client-side flag.

  • Wrong: Trusting a hidden paid=true field or step counter sent by the browser.
  • Why wrong: The client controls it and can set paid=true without paying.
  • Corrected: Store workflow state server-side (an order status column); at fulfillment, re-read status from the database and require status == 'paid'.
  • Recognize/prevent: Ask "could the client lie about this?" for every field that gates a transition.

Debugging tips

When a race fix does not hold (abuse still succeeds sometimes):

  1. Confirm the check and act are truly one statement. If your ORM emits a SELECT then a separate UPDATE, the atomicity is an illusion — inspect the actual SQL your framework generates (enable query logging).
  2. Check the transaction isolation level. A plain read outside a transaction, or READ COMMITTED with a separate select, can still race. The conditional-UPDATE/WHERE pattern works because the write itself takes the row lock.
  3. Verify a uniqueness guarantee exists where it should — e.g., a UNIQUE constraint on (coupon_code) for redeemed_by, or on (user_id, promo_id) — so the database is the final backstop even if app logic slips.
  4. Reproduce with real parallelism. A sequential loop will not trigger the race; you need concurrent requests (thread pool / async burst) in your local lab to see it.

When honest use is wrongly rejected (false positives after the fix):

  1. Distinguish "already redeemed by someone else" (correct 409) from "my own retry." Idempotency keys let a client safely retry without a double-effect and without a spurious rejection.
  2. Check for over-broad locks causing timeouts under load — lock the specific row, not the whole table.

Questions to ask when a logic flaw is suspected:

  • What invariant should always hold here (balance ≥ 0, coupon used ≤ once, price = server price)? Where is it enforced?
  • Which fields in this request does the client control, and which of those gate a decision or get written to a record?
  • Can any step be skipped, repeated, or reordered? What re-reads trusted state before the irreversible action?
  • Under 50 simultaneous copies of this request, what is the worst outcome?

Memory safety

Security & safety: detection and logging.

Logic and race abuse hide in traffic that each looks legitimate, so correlation over time is what exposes them. Log a structured security event at every security-relevant decision.

What to log (per event):

  • Timestamp (UTC, millisecond precision — races live in milliseconds).
  • Source identity: user/account id, session id, source IP, user-agent.
  • Resource acted on: coupon code, order id, target user id, endpoint.
  • The security decision and result: granted / denied, and why (already_redeemed, out_of_range_quantity, unexpected_field:role).
  • A correlation id per request so the check, the write, and the response can be tied together across services.

What signals abuse:

  • A burst of near-simultaneous requests (same user/session/token, same resource, within a few milliseconds) — the classic race fingerprint. Alert on N attempts on one one-time resource inside a short window.
  • Many denied: already_redeemed / denied: unexpected_field for one account — someone is probing logic.
  • Requests carrying fields the endpoint never expects (e.g., role, price, is_admin) — log the presence of the field, never its blocked effect.

What to NEVER log: passwords, tokens, session cookies, API keys, private keys, full card numbers (PANs — mask to last 4), CVV, and any PII you do not need to investigate. Log the coupon code only if codes are not themselves secrets in your system; if they are, log a hash. Logs are an asset attackers want — a log full of secrets is a second breach.

How false positives arise: legitimate double-clicks and network retries produce two near-simultaneous requests that look like a race — this is exactly why idempotency keys matter (they let you tell a retry from an attack). Mobile clients on flaky networks retry aggressively. Tune burst thresholds and treat a couple of duplicates as normal; escalate on dozens.

Real-world uses

Concrete authorized use case. A payments or e-commerce team hires testers for an authorized assessment of a new checkout and promotions system, run entirely against a staging environment the client owns. The testers map the intended purchase and coupon flows, then probe for skipped-payment fulfillment, negative-quantity refunds, price tampering, mass assignment on the profile endpoint, and coupon/gift-card races. Every finding ships with a safe reproduction, an impact and likelihood rating, and a concrete remediation plus retest — not just "it broke."

Authorization checklist (before any lab or engagement):

  • Written scope and permission naming the exact hosts/environments (staging, owned by the client or you).
  • Confirmation you are hitting the authorized environment, never production or a third party.
  • Test data only (lab coupons, test accounts, sandbox payment credentials).
  • A rollback/reset plan so state changes are undone.

Lab cleanup / reset: after testing, reset the database to a seed snapshot (DROP/restore or a migration reset), delete test coupons and orders you created, and clear or archive the lab's security logs so the next run starts clean.

Best-practice habits.

Habit Beginner Advanced
Validation Reject out-of-range values server-side (quantity ≥ 1, known price). Model invariants explicitly; property-test them under concurrency.
Least privilege Allow-list writable fields per endpoint. Separate read/write DB roles; privileged fields only via dedicated services.
Secure defaults Deny unless explicitly permitted; new fields are non-writable by default. Database constraints (UNIQUE, CHECK) as a backstop under all app logic.
Atomicity One conditional UPDATE for check-and-act. Idempotency keys, SELECT ... FOR UPDATE, atomic counters, queue serialization.
Logging Log granted/denied decisions with who/what/when. Correlation ids, burst-detection alerts, anomaly baselining.
Error handling Return a clean 409/400, never leak internals. Fail closed; ensure partial failures roll back the whole transaction.

Practice tasks

All tasks are lab-only: run against a local, isolated instance you own (localhost, a container, or an intentionally-vulnerable training app). Never test third-party systems. Each ends by remediating and verifying.

Beginner 1 — Spot the missing invariant (read-only).

  • Objective: Given a snippet of an order-total endpoint that reads price from the request body, identify the broken invariant and where it should be enforced.
  • Requirements: Write one sentence naming the invariant ("price must equal the server's catalog price") and the exact line where the server must re-derive it.
  • Constraints: No code execution; analysis only.
  • Hints: Ask "which value here does the client control that gates money?"
  • Concepts: business logic flaw, server-side invariants.

Beginner 2 — Kill a mass-assignment bug.

  • Objective: Convert a user.update(request.body) profile endpoint to an allow-list.
  • Requirements: Accept only name and bio; ensure a request with {"name":"x","role":"admin"} leaves role unchanged.
  • Input/Output: Input body with an extra role field → output: role stays user.
  • Constraints: Do not use a deny-list.
  • Hints: Bind named fields; log the presence of any unexpected field.
  • Concepts: mass assignment, allow-listing, logging.

Intermediate 1 — Reproduce and then close a coupon race.

  • Objective: In your lab, show that a two-step (SELECT then UPDATE) coupon endpoint can be redeemed twice under parallel load, then fix it.
  • Requirements: Fire ~50 concurrent redemptions of one coupon; observe more than one success; replace with an atomic conditional UPDATE ... WHERE redeemed_by IS NULL; re-run.
  • Input/Output: Before fix: >1 success. After fix: exactly 1 success, others 409.
  • Constraints: Lab database only; reset state between runs.
  • Hints: Use a thread pool or async burst; check affected rows.
  • Concepts: TOCTOU, atomic critical section, mitigation verification.
  • Defensive conclusion: Remediate with atomicity, then verify the race is closed and honest single use still works.

Intermediate 2 — Enforce workflow order server-side.

  • Objective: Fix a checkout that fulfills when the client sends paid=true.
  • Requirements: Store order status server-side; on fulfillment, re-read status and require status == 'paid'; reject otherwise with 409.
  • Input/Output: Request with client paid=true but DB status pending → rejected.
  • Constraints: Ignore any client-supplied payment flag.
  • Hints: Treat client state as a hint, trusted state as truth.
  • Concepts: state transitions, server-authoritative state.
  • Defensive conclusion: Verify unpaid orders cannot be fulfilled while paid ones can.

Challenge — Idempotent, race-safe gift-card redemption with detection.

  • Objective: Build a redemption endpoint that is safe under bursts and under legitimate client retries, and that logs abuse.
  • Requirements: Use an idempotency key so a retried request returns the first result without double-crediting; use an atomic conditional update so parallel distinct requests cannot double-spend; emit a structured security log (timestamp, user, resource, decision, correlation id) and an alert when N attempts hit one card within a short window.
  • Constraints: Never log the card secret (hash it); reset lab state after.
  • Hints: Idempotency key distinguishes a retry from an attack; the atomic update backstops the race; a UNIQUE constraint is your final safety net.
  • Concepts: idempotency, atomicity, detection/logging, false-positive handling.
  • Defensive conclusion: Demonstrate: honest single redemption succeeds, a retry is a no-op (not a second credit), a 50-way burst credits exactly once, and the log/alert fires — then reset the lab.

Summary

Main concepts. The subtlest, highest-impact web bugs are not payloads — they are logic abuses (using legitimate features in unintended sequences, values, or amounts) and race conditions (TOCTOU: a check and its dependent action are separate, so concurrent requests act on stale truth). Automated scanners miss both because they require understanding intent and concurrency. This extends the IDOR lesson: the server must re-enforce every invariant — authorization, workflow, values, limits, one-time-use — on the trusted side, atomically.

Key techniques/commands. Allow-list writable fields (never spread request.body); derive prices, roles, and amounts from trusted server state; store workflow status server-side and re-check it before irreversible actions; and collapse check-and-act into one atomic operation — a conditional UPDATE ... WHERE <still-valid>, SELECT ... FOR UPDATE, a UNIQUE constraint, or an atomic counter — backed by idempotency keys for safe retries.

Common mistakes. Trusting the UI as the boundary; deny-listing instead of allow-listing fields; "fixing" a race by adding more pre-checks that are still separate from the write; and gating transitions on client-supplied flags.

What to remember. Speed never closes a race — only atomicity does. Enforce invariants server-side, make check-and-act atomic, log the who/what/when/decision of every security-relevant action (never the secrets), watch for millisecond bursts on one-time resources, and only ever test systems you own or are explicitly authorized to test — then reset the lab. Nothing is ever "completely secure"; you reduce and detect risk.

Practice with these exercises