October 15, 2026
Bypassing a Coupon’s usage_limit via a Race Condition in an E-Commerce Platform
TL;DR: A Ruby on Rails e-commerce platform enforces a promotion’s redemption limit (e.g. a single-use coupon) by counting the completed…
By Zyadatef
4 min read
TL;DR: A Ruby on Rails e-commerce platform enforces a promotion's redemption limit (e.g. a single-use coupon) by counting the completed orders that already used the code. That count runs under PostgreSQL's default
READ COMMITTEDisolation, and the only lock in the checkout path is keyed to the order, not the coupon. This leaves an unserialized window between the "check" and the "complete" for different orders sharing the same code. By preparing several orders with the same coupon and completing them at the same instant, every order's limit check sees zero prior redemptions and commits — so a single-use coupon gets redeemed many times over. Confirmed on a live install:usage_count = 4againstusage_limit = 1, reproduced 5 out of 5 runs.
Introduction
On most e-commerce platforms, coupons come with a usage cap: a code for the first 100 customers, one use per account, a one-time free-shipping code, and so on. Logically, the platform must guarantee that cap holds no matter how the orders are timed.
The problem here is that the platform enforces that cap with an application-level COUNT, and that count is not protected against concurrent execution. The result is a TOCTOU (Time-of-Check to Time-of-Use) race condition that turns a limited coupon into an effectively unlimited one.
No elevated privileges are required — any normal customer who can place and complete orders can exploit it.
Background: how the usage limit is enforced
When a customer applies a coupon, the platform checks whether the coupon has exceeded its limit. Simplified logic:
# Promotion model
def usage_limit_exceeded?
usage_limit.present? && usage_count >= usage_limit
end
# Counts completed orders that used the coupon
def usage_count
# COUNT over orders whose state is 'complete' and that are tied to the coupon
adjustments_for_this_promotion.where(orders: { state: 'complete' }).count
end# Promotion model
def usage_limit_exceeded?
usage_limit.present? && usage_count >= usage_limit
end
# Counts completed orders that used the coupon
def usage_count
# COUNT over orders whose state is 'complete' and that are tied to the coupon
adjustments_for_this_promotion.where(orders: { state: 'complete' }).count
endThe intent is simple: "count the orders that have actually finished using this code; if the count reached the limit, block it."
This check runs before the transition to the complete state (wired to before_transition to: :complete).
Where's the flaw?
Three facts combine to open the window:
usage_countonly sees committed orders. An order that is still mid-transaction and hasn't committed yet is not counted.- The default isolation level is
READ COMMITTED. Under this level, each transaction only sees data that was committed at the time of each statement. Two orders running at the same instant don't see each other until one of them commits. - The only lock in the path is per-order. There is a mutex ensuring the same order isn't completed twice simultaneously (a unique row on
order_id), but it does not serialize different orders that share the same coupon. There is noSELECT … FOR UPDATEon the promotion / coupon row.
So the "has the coupon exceeded its limit?" check and the "complete the order" step have a window between them, and that window is not serialized at the coupon level.
Root cause: TOCTOU
The timeline of the attack (4 orders with the same coupon, all completing at once):
Time --------------------------------------------------->
Order 1: [check: usage_count=0 OK] ............... [complete / COMMIT]
Order 2: [check: usage_count=0 OK] ............ [complete / COMMIT]
Order 3: [check: usage_count=0 OK] ........ [complete / COMMIT]
Order 4: [check: usage_count=0 OK] .... [complete / COMMIT]
^
Every order ran its check BEFORE any of them committed,
so they all saw "zero redemptions" and all proceeded.
Result: usage_count = 4 (the limit was 1)Time --------------------------------------------------->
Order 1: [check: usage_count=0 OK] ............... [complete / COMMIT]
Order 2: [check: usage_count=0 OK] ............ [complete / COMMIT]
Order 3: [check: usage_count=0 OK] ........ [complete / COMMIT]
Order 4: [check: usage_count=0 OK] .... [complete / COMMIT]
^
Every order ran its check BEFORE any of them committed,
so they all saw "zero redemptions" and all proceeded.
Result: usage_count = 4 (the limit was 1)All four orders run the usage_count check at roughly the same instant. Because none of them has committed yet, the count returns zero for all of them. So they all pass the check, all complete, and all commit. In the end, the coupon with a limit of 1 was redeemed 4 times.
The difference between correct and faulty behavior here is concurrency, not a missing check — the check exists and works correctly in the sequential case, but it collapses under concurrent execution.
The affected code
The three points that form the vulnerability:
legacy_promotions/.../promotion.rb→usage_limit_exceeded?/usage_count: counts only committed (complete) orders — concurrent in-flight orders are invisible to it.- Eligibility re-check wired to
before_transition to: :complete: the check runs inside each transaction independently, with no serialization against other orders. - The order mutex (unique row on
order_id): locks the order, not the coupon. Different orders using the same code never contend. NoSELECT … FOR UPDATEon the coupon row.
The engineering takeaway: an application-level COUNT under READ COMMITTED cannot enforce an invariant like "this coupon is used only once." That invariant must be enforced at the database level.
Exploitation scenario (high level)
The whole idea is to make the orders complete at the same instant, before any of them commits:
- Create a promotion with a coupon code and
usage_limit = 1. - As a normal customer, prepare several orders (say 4), each with an eligible line item, and apply the same coupon to all of them. They all apply successfully, because no completed order has used the code yet (
usage_count = 0). - Advance all orders to the final checkout step (ready to complete).
- Complete all orders at the same instant — fire the completion request for all four in parallel.
- All orders reach the
completestate with the discount applied, andusage_count == 4despiteusage_limit == 1.
Key point: concurrency is the precondition for the exploit. If you run the same steps sequentially, the second order is rejected with "Coupon code usage limit exceeded" — which is exactly what confirms the defect is the concurrency window, not a missing check.
Proof of Concept and results
Two scripts were written to prove the issue:
- Sequential baseline: completes two orders with the same coupon sequentially, to confirm the guard works correctly in the normal case.
- Concurrent race (the exploit): prepares 4 orders with the same coupon and uses 4 threads on separate DB connections, all held on a barrier that releases at the same instant — so they enter the completion path (
Order#complete!) simultaneously. Repeated over 5 rounds.
A script is necessary here because the completions must run at the same instant to hit the concurrency window — which is hard to do by hand.
Results
Control (sequential) — the guard works correctly:
Promotion #1 code=SEQ3663 usage_limit=1 rules=0
Order A #R859454303 coupon_applied_success=true promo_total=-5.0 total=105.0
usage_count after A = 1
Order B #R545183085 coupon_applied_success=false error="Coupon code usage limit exceeded"
FINAL usage_count = 1 (limit=1)
BASELINE_OK: guard holds sequentiallyPromotion #1 code=SEQ3663 usage_limit=1 rules=0
Order A #R859454303 coupon_applied_success=true promo_total=-5.0 total=105.0
usage_count after A = 1
Order B #R545183085 coupon_applied_success=false error="Coupon code usage limit exceeded"
FINAL usage_count = 1 (limit=1)
BASELINE_OK: guard holds sequentiallyExploit (concurrent) — limit bypassed:
round 0: coupon_applied=4/4 orders_completed=4 usage_count=4 (limit 1) XXXX BYPASS
round 1: coupon_applied=4/4 orders_completed=4 usage_count=4 (limit 1) XXXX BYPASS
round 2: coupon_applied=4/4 orders_completed=4 usage_count=4 (limit 1) XXXX BYPASS
round 3: coupon_applied=4/4 orders_completed=4 usage_count=4 (limit 1) XXXX BYPASS
round 4: coupon_applied=4/4 orders_completed=4 usage_count=4 (limit 1) XXXX BYPASS
==============================
MAX usage_count observed = 4 (usage_limit was 1)
RESULT: RACE CONFIRMED — coupon redeemed past its usage_limitround 0: coupon_applied=4/4 orders_completed=4 usage_count=4 (limit 1) XXXX BYPASS
round 1: coupon_applied=4/4 orders_completed=4 usage_count=4 (limit 1) XXXX BYPASS
round 2: coupon_applied=4/4 orders_completed=4 usage_count=4 (limit 1) XXXX BYPASS
round 3: coupon_applied=4/4 orders_completed=4 usage_count=4 (limit 1) XXXX BYPASS
round 4: coupon_applied=4/4 orders_completed=4 usage_count=4 (limit 1) XXXX BYPASS
==============================
MAX usage_count observed = 4 (usage_limit was 1)
RESULT: RACE CONFIRMED — coupon redeemed past its usage_limitThe result is reproducible 5 out of 5 rounds: usage_count = 4 every round against a limit of 1.
(Insert the attached terminal screenshot here.)
Impact
Any redemption-limited promotion can be defeated:
- Single-use codes.
- "First N customers" codes.
- One-time free-shipping / free-product / percentage-off codes.
An attacker with only normal customer checkout ability turns a bounded discount into an unbounded one, causing direct financial loss (free or deeply discounted goods, drained promotional budgets, resale of abused codes). No elevated privilege is required.
The same counting pattern also governs per-code usage limits (per_code_usage_limit), so those are expected to be bypassable by the same method.