Web Application Security · advanced · ~11 min
**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.
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.
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:
role field can turn any registered user into an administrator.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.
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.
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.
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.
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.
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.
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.
These abuse legitimate features through unintended sequences or values:
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.
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:
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.
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.
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.
# 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
# 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.
Walking the secure redeem function:
code = request.body["code"] — read only the one field this endpoint needs. We do not bind the whole body anywhere.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.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.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.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.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.grant_credit(...) runs inside the same transaction, so credit and redemption commit together — you can never grant credit without also marking the coupon used.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.
Mistake 1 — Trusting the UI as the boundary.
quantity: -5 or quantity: 999999. The UI never runs.quantity is an integer in [1, max_stock]); reject out-of-range values with 400.Mistake 2 — Deny-listing fields instead of allow-listing.
body.delete("role"); user.update(body) to "strip" dangerous fields.is_admin, credit, verified, a new field added next sprint). Deny-lists rot.user.update(name=body["name"]). Privileged fields are set exclusively by trusted server code.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.
if used: reject; sleep; if used again: reject; update(...) — adding more pre-checks.UPDATE ... WHERE unused, SELECT ... FOR UPDATE, unique constraint, or atomic decrement).Mistake 4 — Enforcing step order only with a client-side flag.
paid=true field or step counter sent by the browser.paid=true without paying.status == 'paid'.When a race fix does not hold (abuse still succeeds sometimes):
SELECT then a separate UPDATE, the atomicity is an illusion — inspect the actual SQL your framework generates (enable query logging).READ COMMITTED with a separate select, can still race. The conditional-UPDATE/WHERE pattern works because the write itself takes the row lock.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.When honest use is wrongly rejected (false positives after the fix):
409) from "my own retry." Idempotency keys let a client safely retry without a double-effect and without a spurious rejection.Questions to ask when a logic flaw is suspected:
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):
granted / denied, and why (already_redeemed, out_of_range_quantity, unexpected_field:role).What signals abuse:
denied: already_redeemed / denied: unexpected_field for one account — someone is probing logic.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.
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):
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. |
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).
price from the request body, identify the broken invariant and where it should be enforced.Beginner 2 — Kill a mass-assignment bug.
user.update(request.body) profile endpoint to an allow-list.name and bio; ensure a request with {"name":"x","role":"admin"} leaves role unchanged.role field → output: role stays user.Intermediate 1 — Reproduce and then close a coupon race.
UPDATE ... WHERE redeemed_by IS NULL; re-run.409.affected rows.Intermediate 2 — Enforce workflow order server-side.
paid=true.status server-side; on fulfillment, re-read status and require status == 'paid'; reject otherwise with 409.paid=true but DB status pending → rejected.Challenge — Idempotent, race-safe gift-card redemption with detection.
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.