Race condition vulnerability in a single-use coupon endpoint
We reproduced a race condition vulnerability in a single-use coupon endpoint running on Python's threaded HTTP server. Widening the gap between the check and the write made sixteen concurrent redemption attempts against one coupon succeed sixteen times out of sixteen, every round. With no added delay the same flaw still let through extra redemptions on three of five rounds, with 3, 8 and 4 successes. A single conditional SQL update closed the gap and held at exactly one success across every retest.
This is a scripted Python and curl experiment with downloadable source and responses. The sixteen concurrent requests were sent with Python threads calling the standard library's urllib, not curl --parallel; curl sent the sequential control requests. No AI agent or model ran the test.
Define the race condition vulnerability and test scope
A race condition vulnerability appears when a server checks a condition, then acts on that check, and a second request changes the underlying state somewhere in between. The two steps are not one operation, so correctness depends on no other request landing in the gap. Our fixture issues a coupon, SAVE10, with exactly one use. The rule is that one redemption request should succeed and every other concurrent request should receive a 409.
This fixture demonstrates CWE-367, Time-of-check Time-of-use (TOCTOU) Race Condition, a specific case of the broader CWE-362, Concurrent Execution using Shared Resource with Improper Synchronization. A race condition attack against a coupon, a credit balance, or an inventory count is sometimes described as a double spend, after the same failure mode in payment systems: two requests both pass a check that was only ever supposed to let one through.
- Target: one HTTP server built on
http.server.ThreadingHTTPServer, bound to IPv4 loopback,127.0.0.1. Each connection runs on its own thread, the same model many small Python services use. The runner selects free ports. - Allowed requests:
POST /redeemwith a coupon code header, andGET /statusto read a coupon's remaining uses. - Coupons:
SAVE10for the concurrent burst, and a separateCONTROL1used only for a sequential positive and negative control. Both are fixed synthetic demo codes. - Data: a temporary on-disk SQLite file created fresh per server process and removed on shutdown. There are no customer records or external services.
The fixture is intentionally insecure in its vulnerable mode and has no configurable bind address; it always listens on loopback. For a staging engagement, agree the hosts, accounts, records and stopping conditions with the system owner before replacing this scope. The Excession Labs Acceptable Use Policy describes authorized use. This article gives no permission to test another system.
Run the downloadable fixture
Download fixture.py and verify.py into the same directory. You need Python 3.9 or later and curl; the Python code uses only the standard library. Inspect the source, then run:
python3 verify.py --output rerun
The output directory must be new. The runner starts a fresh fixture process for each of three conditions, five times each: vulnerable-baseline (no added delay), vulnerable-widened (a stated 50 ms sleep inserted between the check and the write), and fixed (one conditional UPDATE, no separate check step at all). Each round runs a negative control, a sequential positive and negative control on CONTROL1, a burst of sixteen simultaneous redemption attempts on SAVE10, and a final read of the coupon's remaining uses, then stops the server.
Our recorded command was python3 verify.py --output recorded-run. It exited successfully, having run fifteen rounds with no failed controls and exactly one success in every fixed round. The run on 10 November 2026 used fixture and runner version 1.0.0, Python 3.15.2 and curl 8.7.1 on macOS ARM64. Exact runtime strings, UTC timestamps, every command, every round's raw counts and source SHA-256 hashes are in results.json. A rerun will use different ports, timestamps and, because this is a race, a different spread of outcomes in the unfixed modes.
Observed results: how many redemptions actually succeeded
The single-use policy says one of the sixteen concurrent requests per round should succeed. The recorded run produced these counts. Each round used a fresh server and a fresh coupon:
| Condition | Added check-to-write delay | Round 1 | Round 2 | Round 3 | Round 4 | Round 5 | Policy expects |
|---|---|---|---|---|---|---|---|
| vulnerable-baseline | none | 3 | 1 | 8 | 1 | 4 | 1 |
| vulnerable-widened | 50 ms | 16 | 16 | 16 | 16 | 16 | 1 |
| fixed | n/a | 1 | 1 | 1 | 1 | 1 | 1 |
Two rounds of the unwidened baseline landed on exactly one success, the correct outcome, by chance. An unwidened race on one laptop does not fail every time. With a stated 50 millisecond sleep between the read and the write, all sixteen requests succeeded in each of the five rounds. The fixed condition held at exactly one success in all five rounds with no added delay at all, because it has no window for a second request to land in.
The sequential controls on CONTROL1 passed in every round and every condition, including the vulnerable ones: a solo first redemption returned 200, a solo second redemption returned 409. The flaw appears only under concurrency.
Verify the finding before reporting it
In the recorded first round of the widened condition, the server reported uses_remaining: -15 after the burst, on port 56808. This is the final status check, reformatted across lines:
curl --noproxy '*' --silent --show-error --include \
--http1.1 --connect-timeout 1 --max-time 3 --request GET \
'http://127.0.0.1:56808/status?code=SAVE10'
The recorded response:
HTTP/1.0 200 OK
{"code": "SAVE10", "uses_remaining": -15}
A negative remaining count is strong evidence on its own: the counter started at one, and ending at negative fifteen means sixteen decrements were applied to a coupon that was supposed to allow exactly one. results.json also records each of the sixteen individual responses, their status codes and bodies, so the finding does not rest on the counter alone. A weaker form of this evidence would be sixteen 200 responses with no record of the final state, since a server could return 200 before actually writing anything.
A useful finding records the endpoint, the shared resource, the check and the write it should have been one operation, the number of concurrent requests sent, how many succeeded against how many should have, and the resulting state of the resource. Here the impact is one coupon consumed sixteen times instead of once. This experiment does not establish a production severity rating; that depends on what the coupon is worth and whether the application has any other backstop, such as a reconciliation job.
Fix: an atomic check-and-update, and retest
The same fixture source has two code paths. In vulnerable mode, the handler reads the row, sleeps, then writes a separate decrement:
row = check_conn.execute(
"SELECT uses_remaining FROM coupons WHERE code = ?", (code,)
).fetchone()
remaining = row[0]
if remaining <= 0:
return self.reply(409, {"error": "coupon_exhausted"})
time.sleep(delay)
write_conn.execute(
"UPDATE coupons SET uses_remaining = uses_remaining - 1 WHERE code = ?", (code,)
)
Fixed mode replaces both statements with one conditional update that is itself the check:
cur = conn.execute(
"UPDATE coupons SET uses_remaining = uses_remaining - 1 "
"WHERE code = ? AND uses_remaining > 0",
(code,),
)
if cur.rowcount == 1:
return self.reply(200, {"status": "redeemed", "code": code})
There is no read step to race against, because the condition and the write happen inside the same statement. SQLite's documentation on transactions says that any SQL command that is not already inside a BEGIN automatically starts an implicit transaction, which commits when that statement finishes. SQLite allows one writer per database at a time. A second connection's UPDATE waits, up to the 5 second busy timeout, for the first to commit, then evaluates uses_remaining > 0 against the new value. Other databases give the same guarantee for a single conditional UPDATE through row locks. If the data lives in the memory of one process, a lock held across the check and the write also closes the gap. A lock does not help when several processes or hosts serve the endpoint.
The runner reran the full sequence against fixed mode for five fresh rounds, each with a new database and a new coupon. Every round produced exactly one 200 and fifteen 409 responses, and the final status check returned uses_remaining: 0 each time, never negative. The sequential controls on CONTROL1 still passed, so the fix did not block the legitimate single redemption it was meant to allow.
Inspect the evidence bundle
- Machine-readable results: all fifteen rounds, every one of the sixteen burst responses per round, expected and observed outcomes, and source hashes.
- Exact recorded commands: the curl invocations and the fixture startup command for every round. Ports were ephemeral and are no longer listening.
- SHA-256 manifest: compare downloaded sources and evidence. This is an integrity check, not independent attestation.
- Fixture instructions and limitations, server source and verification source.
All coupon codes and the data they touch are synthetic public demo values. The bundle includes the actual recorded HTTP response text for a representative round of each condition, in addition to the summary counts quoted above.
What this experiment does not cover
Sixteen threads against one in-process SQLite file on one laptop show one check-then-act window closing under one fix. We did not test a real production database under production-scale concurrent load, where lock contention and connection pooling behave differently than a temp file does. We did not test a distributed system where the check and the write happen on different hosts, a file-system race such as a symlink swap. We did not test races inside authorization checks.
For a real engagement, identify every check-then-act sequence that touches a limited resource, such as a rate limit, a one-time code, or a stock count, and test each with concurrency that matches production traffic rather than sixteen threads on one laptop. Keep the unfixed baseline's results alongside the fixed retest, including rounds where the race happened not to trigger. Related categories worth reviewing on the same system include API rate limiting, broken access control, and the OWASP API Top 10 as a whole.
Excession Labs tests for race condition vulnerabilities as part of its security assessments. See how a finding like this looks in a sample report.