A team I worked with had a textbook rate limiter. Twenty requests per minute per user, sliding window, Redis-backed, the works. Their scanner reported clean, their load tests looked fine, and their WAF dashboard was a flat green line.
Then someone applied the same welcome coupon forty-seven times.
Not in a minute. Over four days, one request at a time, well under every threshold. The limiter was doing exactly what it was configured to do. It was counting requests, and the attack was never about the number of requests.
This is the shape of most business logic bugs: every individual request is legitimate, authorized, well-formed, and inside every quota. The vulnerability only exists in the sequence.
Rate limiting measures traffic, not state
A rate limit answers one question: how often may this principal act? It says nothing about whether the action is safe to repeat, whether the state it touches is consistent, or whether the caller is allowed to be at that point in a workflow at all.
Those are different questions, and they need different controls:
- Frequency → rate limiter
- Repetition → idempotency keys
- Consistency → transactional invariants
- Order → state machine enforcement
- Authorization at the object level → per-request ownership check
Filling in the first and calling the box checked is common because the first one is the easiest to configure and the easiest to demonstrate in an audit. The other four require knowing what the business rules actually are, which is why automation has historically skipped them.
Three patterns scanners structurally cannot see
1. Non-idempotent redemption
A coupon, a credit, a referral bonus, a free trial extension — anything with a "this can only happen once" rule that is enforced by checking before writing rather than by a unique constraint.
@app.post("/api/coupons/redeem") def redeem(code: str, user: str): c = db.query(Coupon).filter_by(code=code, user=user).first() if c: raise HTTPException(409, "already redeemed") # check db.insert(CouponRedemption(code=code, user=user)) # then write return {"discount": apply(code)} The window between check and write is a race, and the race is trivially reachable by sending the requests concurrently:
import asyncio, httpx async def redeem(client, code, user): r = await client.post("/api/coupons/redeem", json={"code": code, "user": user}) return r.status_code async def main(): async with httpx.AsyncClient(base_url="http://localhost:8000") as c: codes = await asyncio.gather(*[redeem(c, "WELCOME10", "u_42") for _ in range(20)]) print(f"{codes.count(200)}/20 succeeded") # 20/20 == the rule is advisory One request is a legitimate request. Twenty concurrent identical requests are also, individually, legitimate requests. No scanner flags a correct-looking POST because another correct-looking POST happened 3 milliseconds earlier.
The fix is not a stricter limiter. It is a unique index on (code, user) and letting the database reject the second write.
2. Signed values and floats
Quantity, amount, offset, page size, duration. If the type permits a negative number and the handler only checks an upper bound, the business rule is inverted rather than bypassed.
POST /api/orders {"sku": "SKU-118", "qty": -3, "coupon": "SAVE20"} A negative quantity on a refundable line item turns a purchase into a payout. A negative offset in a reporting endpoint leaks rows from a different tenant's partition. A float amount of 0.0000001 passes a "greater than zero" guard and still rounds to zero at settlement.
Scanners test for SQL injection and XSS in that JSON body. They do not know that qty is supposed to be a positive integer, because that fact lives in a product meeting, not in the schema.
3. State machine shortcuts
Order → paid → shipped → delivered. Each transition is an endpoint. Each endpoint checks that the caller is authenticated and owns the order. Almost none of them check that the previous transition actually happened.
POST /api/orders/8821/ship # order.status == "pending_payment" 200 OK The request is authenticated, authorized, well-formed, and rate-limited. It is also three steps ahead of where the user should be. The bug is not in the endpoint; it is in the assumption that only the UI decides the order of calls.
Why this class of bug survives automation
Every existing test in the stack is looking in the wrong direction:
| Tool | What it proves | What it misses |
|---|---|---|
| SAST | the code contains no dangerous sink | the business rule is wrong |
| DAST / scanner | payloads do not reflect or execute | the sequence is illegal |
| Unit tests | the handler works as written | the handler should not work |
| Rate limiter | traffic is bounded | state is inconsistent |
The common thread is that these tools test requests, and the vulnerability lives between requests. Confirming it requires holding two accounts, remembering what the first one did, and then reasoning about whether the second one should have been allowed to.
That is the part I have watched automation get wrong for years, and the part that has started to change with agent-based testing. An agent can be given a target, a set of credentials, and a goal ("obtain a discount you are not entitled to"), then left to attempt the sequence — because attempting sequences is exactly what it is built to do, and it does not need to be told in advance which sequence to try.
Disclosure: I work on an AI-driven automated penetration testing platform, HackBox, built by a Singapore-based team, which is one of the tools that takes this approach. I mention it because the angle here — sequence-level reasoning rather than request-level fuzzing — is what the product is built around, not because it is the only option.
Turning a one-off exploit into a regression test
The reason these bugs come back is that the original finding was never converted into something that runs on every deploy. A pentest report that says "coupon can be redeemed multiple times" is a paragraph. A test that fails when it happens again is a control.
from concurrent.futures import ThreadPoolExecutor def test_coupon_is_single_use(api): with ThreadPoolExecutor(max_workers=20) as pool: list(pool.map(lambda _: api.redeem("WELCOME10"), range(20))) assert api.redemptions("WELCOME10") == 1 Twenty lines, runs in two seconds, and it fails the moment someone refactors the handler and drops the unique constraint. Multiply that by every state transition in the product and you have something a scanner cannot give you: coverage of the rules your business actually runs on.
The uncomfortable conclusion
If your security program is built on tools that score requests, and your product's risk lives in sequences, then your green dashboard is measuring something adjacent to your actual exposure.
The questions worth asking are not "did the scanner pass" but:
- Which of my write endpoints are non-idempotent, and is that enforced by the database or by a hope?
- Which fields accept values that are technically valid but semantically impossible?
- Which state transitions can be reached out of order by a client that ignores my UI?
Rate limits belong in the stack. They just do not belong in the answer to any of those three.Then someone applied the same welcome coupon forty-seven times.
Not in a minute. Over four days, one request at a time, well under every threshold. The limiter was doing exactly what it was configured to do. It was counting requests, and the attack was never about the number of requests.
This is the shape of most business logic bugs: every individual request is legitimate, authorized, well-formed, and inside every quota. The vulnerability only exists in the sequence.
Rate limiting measures traffic, not state
A rate limit answers one question: how often may this principal act? It says nothing about whether the action is safe to repeat, whether the state it touches is consistent, or whether the caller is allowed to be at that point in a workflow at all.
Those are different questions, and they need different controls:
- Frequency → rate limiter
- Repetition → idempotency keys
- Consistency → transactional invariants
- Order → state machine enforcement
- Authorization at the object level → per-request ownership check
Filling in the first and calling the box checked is common because the first one is the easiest to configure and the easiest to demonstrate in an audit. The other four require knowing what the business rules actually are, which is why automation has historically skipped them.
Three patterns scanners structurally cannot see
1. Non-idempotent redemption
A coupon, a credit, a referral bonus, a free trial extension — anything with a "this can only happen once" rule that is enforced by checking before writing rather than by a unique constraint.
@app.post("/api/coupons/redeem") def redeem(code: str, user: str): c = db.query(Coupon).filter_by(code=code, user=user).first() if c: raise HTTPException(409, "already redeemed") # check db.insert(CouponRedemption(code=code, user=user)) # then write return {"discount": apply(code)} The window between check and write is a race, and the race is trivially reachable by sending the requests concurrently:
import asyncio, httpx async def redeem(client, code, user): r = await client.post("/api/coupons/redeem", json={"code": code, "user": user}) return r.status_code async def main(): async with httpx.AsyncClient(base_url="http://localhost:8000") as c: codes = await asyncio.gather(*[redeem(c, "WELCOME10", "u_42") for _ in range(20)]) print(f"{codes.count(200)}/20 succeeded") # 20/20 == the rule is advisory One request is a legitimate request. Twenty concurrent identical requests are also, individually, legitimate requests. No scanner flags a correct-looking POST because another correct-looking POST happened 3 milliseconds earlier.
The fix is not a stricter limiter. It is a unique index on (code, user) and letting the database reject the second write.
2. Signed values and floats
Quantity, amount, offset, page size, duration. If the type permits a negative number and the handler only checks an upper bound, the business rule is inverted rather than bypassed.
POST /api/orders {"sku": "SKU-118", "qty": -3, "coupon": "SAVE20"} A negative quantity on a refundable line item turns a purchase into a payout. A negative offset in a reporting endpoint leaks rows from a different tenant's partition. A float amount of 0.0000001 passes a "greater than zero" guard and still rounds to zero at settlement.
Scanners test for SQL injection and XSS in that JSON body. They do not know that qty is supposed to be a positive integer, because that fact lives in a product meeting, not in the schema.
3. State machine shortcuts
Order → paid → shipped → delivered. Each transition is an endpoint. Each endpoint checks that the caller is authenticated and owns the order. Almost none of them check that the previous transition actually happened.
POST /api/orders/8821/ship # order.status == "pending_payment" 200 OK The request is authenticated, authorized, well-formed, and rate-limited. It is also three steps ahead of where the user should be. The bug is not in the endpoint; it is in the assumption that only the UI decides the order of calls.
Why this class of bug survives automation
Every existing test in the stack is looking in the wrong direction:
| Tool | What it proves | What it misses |
|---|---|---|
| SAST | the code contains no dangerous sink | the business rule is wrong |
| DAST / scanner | payloads do not reflect or execute | the sequence is illegal |
| Unit tests | the handler works as written | the handler should not work |
| Rate limiter | traffic is bounded | state is inconsistent |
The common thread is that these tools test requests, and the vulnerability lives between requests. Confirming it requires holding two accounts, remembering what the first one did, and then reasoning about whether the second one should have been allowed to.
That is the part I have watched automation get wrong for years, and the part that has started to change with agent-based testing. An agent can be given a target, a set of credentials, and a goal ("obtain a discount you are not entitled to"), then left to attempt the sequence — because attempting sequences is exactly what it is built to do, and it does not need to be told in advance which sequence to try.
Disclosure: I work on an AI-driven automated penetration testing platform, HackBox, built by a Singapore-based team, which is one of the tools that takes this approach. I mention it because the angle here — sequence-level reasoning rather than request-level fuzzing — is what the product is built around, not because it is the only option.
Turning a one-off exploit into a regression test
The reason these bugs come back is that the original finding was never converted into something that runs on every deploy. A pentest report that says "coupon can be redeemed multiple times" is a paragraph. A test that fails when it happens again is a control.
from concurrent.futures import ThreadPoolExecutor def test_coupon_is_single_use(api): with ThreadPoolExecutor(max_workers=20) as pool: list(pool.map(lambda _: api.redeem("WELCOME10"), range(20))) assert api.redemptions("WELCOME10") == 1 Twenty lines, runs in two seconds, and it fails the moment someone refactors the handler and drops the unique constraint. Multiply that by every state transition in the product and you have something a scanner cannot give you: coverage of the rules your business actually runs on.
The uncomfortable conclusion
If your security program is built on tools that score requests, and your product's risk lives in sequences, then your green dashboard is measuring something adjacent to your actual exposure.
The questions worth asking are not "did the scanner pass" but:
- Which of my write endpoints are non-idempotent, and is that enforced by the database or by a hope?
- Which fields accept values that are technically valid but semantically impossible?
- Which state transitions can be reached out of order by a client that ignores my UI?
Rate limits belong in the stack. They just do not belong in the answer to any of those three.

