Applying single-use coupons multiple times via TOCTOU race conditions, code reuse, or referral combos that bypass intended redemption limits.
TL;DR
SELECT ... FOR UPDATE, idempotency keys, and server-side price recomputation.Discount stacking is a business-logic vulnerability in which a single-use coupon, referral bonus, or promotional credit is redeemed more times than the application intends. The canonical instance is a Time-Of-Check / Time-Of-Use (TOCTOU) race condition on a /apply-coupon endpoint, but the same business outcome can be reached via sequential code reuse, cross-account replay, or by stacking incompatible promotion types in a single order. The vulnerability class is mapped to CWE-841 (Improper Enforcement of Behavioral Workflow) and to OWASP A04:2021 — Insecure Design, because the flaw is not a missing input filter but a missing concurrency or policy control on a state transition.
The CVSS v3.1 base score is 6.5 (Medium) with vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N. Confidentiality and availability are unaffected, but integrity is high because price, order total, and revenue ledgers are directly corrupted by the attacker. In practice the business impact frequently exceeds the CVSS rating: a single attacker running Burp Suite Turbo Intruder can drain a six-figure promotional budget in seconds, and the loss scales linearly with automation rather than with attacker skill.
It is important to distinguish discount stacking from generic price manipulation. Price manipulation modifies a client-supplied total ({"price": -10}) and is fixed by recomputing the total server-side. Discount stacking, by contrast, sends the correct coupon code to the correct endpoint with the correct user identity — the server simply fails to enforce that one code equals one redemption when N concurrent requests collide. The two often coexist, but the controls are different: server-side recomputation does not protect against a race condition on the coupon table itself.
The vulnerable flow contains a measurable gap between reading coupon state and writing it. During that window, parallel requests all observe used = false, all proceed, and all commit a redemption.
The exploit reduces to four ordered steps that any tester can reproduce:
POST /apply-coupon request in a proxy and replay it once in Repeater to confirm the baseline behaviour.200 OK for a single-use code, a discount sum exceeding the cart total, or a duplicated discount_id in the order line items confirms the race.A representative exploit request looks like this:
POST /checkout/apply-promo HTTP/2
Host: shop.example.com
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json
{"promo_code": "FIRST50", "order_id": "ord_88abc"}Twenty copies of this request, gated on the last byte and released simultaneously, will apply FIRST50 twenty times on a vulnerable server. The race window is typically 1 to 50 milliseconds on a CDN-fronted application and considerably wider on a monolithic backend under load.
A request that looks idempotent — same body, same headers, same JWT — is not safe by default. Idempotency must be enforced server-side with a key, a row-level lock, or a unique constraint. HTTP semantics alone do not protect a stateful redemption.
The TOCTOU race is the canonical case, but the same financial outcome is reachable through several non-concurrent paths that defenders often miss.
| Variant | Technique | Impact |
|---|---|---|
| Parallel race | 20-50 identical redemptions via HTTP/2 single packet | Coupon redeemed N times before any state is written |
| Code reuse | Single-use code invalidated only by async worker | Replay code in the window between confirmation and burn |
| Cross-account | Same shared cart consumes coupons from N free accounts | Aggregated discount on one order, costless account creation |
| Referral + coupon | Referral bonus and promo applied in the same transaction | Compounded credit, often pushes total to zero |
| Loyalty + discount | Points and coupon applied without mutual-exclusion check | Negative final price, refund of absolute value |
| Negative price stacking | Multiple percentage discounts applied sequentially | Final total below zero, account credited |
| Gift card + promo | Gift card consumed first, discount applied to remainder | Coupon discount becomes free credit on next order |
The cross-account and referral variants are particularly hard to detect because each individual request is well-formed and authenticated as a different legitimate user. Detection here requires graph-level analysis (cycle detection in the referral graph, device-fingerprint correlation across accounts) rather than per-request validation.
Researcher Jack Cable (@cablej) reported in 2016 that Instacart's coupon redemption endpoint allowed the same promo code to be redeemed twice via concurrent requests. The backend checked redemption status and then applied the credit, but the update was not atomic. Instacart awarded a $200 bounty. After the initial patch, Cable found a bypass that redeemed two different codes simultaneously, still stacking the savings — a reminder that fixing the surface request without fixing the underlying transaction model leaves the class of bug intact.
In January 2023, researcher @ian discovered that Stripe's fee-discount acceptance endpoint was vulnerable to a race condition. By calling the discount acceptance endpoint 30 times in parallel via Burp Suite Turbo Intruder, he accumulated $600,000 in fee-free transaction volume from a single $20,000 promotional offer. Stripe's loss per abuse — about $600 (3 percent of $20K) — was far smaller than the headline number, but the report illustrates how a few seconds of automation can multiply a finite promotion by 30x. Stripe paid $5,000 and resolved the issue with server-side idempotency on the discount acceptance call.
A separate Stripe report from September 2022 showed that promotion codes carrying explicit redemption limits (for example limit=1) could be redeemed beyond their cap via a race condition on Stripe Checkout links. The exploit required no special tooling — only a browser opening the same checkout link in multiple tabs concurrently. Stripe paid $250 and fixed the issue with atomic redemption tracking. The low bounty relative to #1849626 reflects the smaller direct loss, but the root cause and class are identical.
Not every discount-stacking incident is a race condition. CVE-2020-36841 in WooCommerce Smart Coupons (versions ≤4.6.0) allowed unauthenticated users to send themselves gift certificates of arbitrary value by abusing an authorization bypass in the coupon creation logic. The financial impact is functionally equivalent to a successful stacking attack — attackers fabricated discount value out of nothing — but the root cause is missing access control on the create endpoint rather than a TOCTOU window. Patched in version 4.6.5. The advisory is tracked at GHSA-x279-24jv-7gr3.
A patched race condition often reappears under a different request shape. After fixing the obvious endpoint, sweep every adjacent surface — referral application, gift card redemption, loyalty point conversion — because they typically share the same non-atomic check-then-update pattern.
Manual testing for discount stacking is fast once the discount surfaces are mapped. The procedure is identical for coupons, referrals, gift cards, and loyalty redemptions.
POST /apply-coupon request in Burp Proxy.race-single-packet-attack.py template. Set 20 parallel requests gated on the same word. Open the gate; all twenty bytes leave together over a single HTTP/2 connection.current_uses column. Multiple 200 OK responses, a discount sum greater than the cart, or current_uses lower than the request count all confirm the race.quantity: -1, discount_percent: 150, two different valid codes in parallel, the same code from two accounts on the same device.The Turbo Intruder template that drives the exploit is short:
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2)
for i in range(20):
engine.queue(target.req, gate='race1')
engine.openGate('race1')
def handleResponse(req, interesting):
table.add(req)Programmatic detection focuses on signals that are reliable across stacks: response distribution, side-effect divergence, and database invariants.
| Signal | Indicator |
|---|---|
Multiple 200 OK for a single-use code | Race window confirmed |
| Discount sum greater than cart total | No floor clamping at zero |
coupon_used_count < request count post-exploit | Atomic write failure |
Duplicate discount_id in order line items | Missing idempotency key |
| Response time variance < 5 ms across batch | Tight race window present, exploit reliable |
Open-source tools that automate this surface include the Burp Race the Web extension, Turbo Intruder with the single-packet template, ffuf with high concurrency (-t 30) for sequential probing, and custom Python asyncio plus httpx scripts for precise timing. Caido offers a similar replay-group capability for teams that have moved away from Burp.
BreachVex detects discount stacking through dedicated race-condition probing on coupon, promo, referral, and gift-card endpoints discovered during attack-surface mapping. The scanner replays each candidate request concurrently, compares the response distribution against a single-shot baseline, and emits a CONFIRMED finding only when the side-effect divergence (multiple successful redemptions, exceeded max_uses, or duplicated discount line items) is observed, with every confirmed finding backed by a dual-judge proof flow.
The two controls below work together. The atomic transaction prevents the race on the coupon row itself, and the idempotency key prevents the same logical operation from being attempted twice in any other guise.
The vulnerable pattern reads coupon state outside a lock and writes it later:
# VULNERABLE — TOCTOU window between check and update
async def apply_coupon(order_id: str, code: str, user_id: str) -> dict:
coupon = await db.fetch_one("SELECT * FROM coupons WHERE code=:code AND used=false", {"code": code})
if not coupon:
raise ValueError("Invalid coupon")
# ...network round-trip, ORM overhead, no lock held...
await db.execute("UPDATE coupons SET used=true WHERE id=:id", {"id": coupon.id})
await db.execute("INSERT INTO redemptions (coupon_id, user_id, order_id) VALUES (:c, :u, :o)",
{"c": coupon.id, "u": user_id, "o": order_id})
return {"discount": float(coupon.discount_amount)}The fixed pattern wraps the read and the write in a single transaction with a row-level lock, so concurrent transactions block on SELECT ... FOR UPDATE rather than racing past it:
# FIXED — SELECT FOR UPDATE holds a row lock for the duration of the tx
from sqlalchemy import text
async def apply_coupon(order_id: str, code: str, user_id: str) -> dict:
async with get_db() as db:
async with db.begin():
coupon = await db.execute(text("""
SELECT id, discount_amount, max_uses, current_uses
FROM coupons
WHERE code = :code
AND is_active = true
AND (expires_at IS NULL OR expires_at > now())
FOR UPDATE NOWAIT
"""), {"code": code}).fetchone()
if not coupon:
raise ValueError("Coupon not found or expired")
if coupon.current_uses >= coupon.max_uses:
raise ValueError("Coupon exhausted")
existing = await db.execute(text(
"SELECT 1 FROM coupon_redemptions WHERE coupon_id=:cid AND user_id=:uid"
), {"cid": coupon.id, "uid": user_id}).fetchone()
if existing:
raise ValueError("Already redeemed by this user")
await db.execute(text(
"UPDATE coupons SET current_uses = current_uses + 1 WHERE id = :cid"
), {"cid": coupon.id})
await db.execute(text("""
INSERT INTO coupon_redemptions (coupon_id, user_id, order_id)
VALUES (:cid, :uid, :oid)
"""), {"cid": coupon.id, "uid": user_id, "oid": order_id})
return {"discount_amount": float(coupon.discount_amount)}A UNIQUE(coupon_id, user_id) constraint on coupon_redemptions provides a database-enforced safety net: even if a concurrency bug slips past the application code, the second INSERT fails with a constraint violation rather than silently double-redeeming.
For higher-value flows — payment-bearing checkouts, fee discount acceptance — pair the atomic transaction with a server-side idempotency key derived from the operation, not from a client-supplied header. The example below uses Redis SET NX to acquire a short-lived lock per (user, code, order) and a Knex transaction with forUpdate to perform the redemption atomically:
const Redis = require('ioredis');
const client = new Redis();
async function applyCoupon(req, res) {
const { code, orderId } = req.body;
const userId = req.user.id;
// Idempotency key: one application per (user, code, order)
const idemKey = `coupon:${userId}:${code}:${orderId}`;
const acquired = await client.set(idemKey, '1', 'NX', 'EX', 300); // 5 min TTL
if (!acquired) {
return res.status(409).json({ error: 'Coupon application already in progress' });
}
try {
const result = await db.transaction(async (trx) => {
const coupon = await trx('coupons')
.where({ code, is_active: true })
.forUpdate()
.first();
if (!coupon || coupon.current_uses >= coupon.max_uses) {
throw new Error('Coupon invalid or exhausted');
}
await trx('coupons').where({ id: coupon.id }).increment('current_uses', 1);
await trx('coupon_redemptions').insert({
coupon_id: coupon.id, user_id: userId, order_id: orderId,
});
return { discount: coupon.discount_amount };
});
return res.json(result);
} finally {
await client.del(idemKey);
}
}Two further controls round out a defensible implementation. First, recompute the order total server-side on every checkout submission and clamp the result with max(final_price, 0) so that no combination of discounts can produce a negative charge. Second, never burn coupons in an asynchronous worker — the window between commit and burn is the exact bug exploited in the Stripe and Instacart reports above.
Rate limiting alone — for example "max 3 /apply-coupon requests per user per 10 seconds" — does not prevent a single-packet attack. Twenty requests can leave the attacker's machine in well under one millisecond and arrive at the application before any rate limiter has a chance to count them. Use atomic transactions and idempotency keys; treat rate limiting as defence in depth.
Discount stacking is a business logic flaw where a single-use coupon, referral bonus, or promotional credit is redeemed more times than the application intends. The canonical exploit is a TOCTOU race condition on /apply-coupon endpoints, but the same outcome is reachable via sequential code reuse or by combining incompatible promotion types (loyalty + coupon + gift card). Mapped to CWE-841 and OWASP A04:2021 (renamed A06:2025 — Insecure Design). Real cases include HackerOne #1717650 (Stripe, $250) and HackerOne #1849626 (Stripe, $5K, $600K loss).
Time-Of-Check / Time-Of-Use (TOCTOU) exploits the gap between SELECT (check used=false) and UPDATE (set used=true) in coupon redemption. If 30 parallel requests arrive before any UPDATE commits, all 30 read used=false, all proceed to insert a discount, and the single-use code applies 30 times. The fix is atomic enforcement via SELECT ... FOR UPDATE inside a transaction, or a UNIQUE constraint on (coupon_id, order_id) so duplicate inserts fail at the database layer.
Published by James Kettle (PortSwigger Research, August 2023, 'Smashing the State Machine'), the HTTP/2 single-packet attack bundles 30 or more requests into the last frame of a single HTTP/2 connection. All requests arrive at the server within a sub-1ms window — turning remote race conditions into local ones. Burp Suite Turbo Intruder ships a race-single-packet-attack.py template. The @ryotkak h2spacex tool (August 2024) extends this to 10,000 requests in ~166ms. This is how Stripe was hit for $600K (HackerOne #1849626).
In January 2023, researcher @ian discovered that Stripe's fee-discount acceptance endpoint was vulnerable to a race condition (HackerOne #1849626). By calling the endpoint 30 times in parallel via Burp Suite Turbo Intruder, he accumulated $600,000 in fee-free transaction volume from a single $20,000 promotional offer — a 30× multiplication of a single one-shot discount. Stripe's actual cost was around $600 (3% of $20K), but the report illustrates how seconds of automation can multiply a finite promotion. Stripe paid $5,000 and patched with server-side idempotency.
Idempotency keys make repeated submissions of the same operation produce the same effect exactly once. Implementation: client generates a UUID Idempotency-Key header per intended redemption; server stores it in Redis with SET key value NX EX 300 (NX = only if not exists, 300s TTL). If the SET fails, the request is a duplicate and is rejected before any state change. Combine with a UNIQUE constraint on (coupon_id, order_id) for defense in depth. Stripe and Shopify both ship official idempotency-key support.
Default PostgreSQL/MySQL transaction isolation is READ COMMITTED, which permits non-repeatable reads — two concurrent transactions each see used=false, each commits the redemption, and only application-level coordination (locks, idempotency keys, unique constraints) prevents the duplicate. Switching to SERIALIZABLE isolation forces the database to detect conflicts but adds latency and rejection retries. The robust fix is SELECT ... FOR UPDATE with NOWAIT inside a transaction, plus a UNIQUE constraint as a backstop.
SELECT ... FOR UPDATE acquires a row-level lock for the duration of the surrounding transaction. Other transactions attempting the same SELECT FOR UPDATE block until the first transaction commits or rolls back. Wrapped around the coupon read-and-update sequence, it serializes redemption attempts so only the first request observes used=false; subsequent requests block, then read used=true and reject. NOWAIT raises an error instead of blocking — useful for fast-fail behavior under heavy parallel load. Requires an explicit transaction (BEGIN/COMMIT).