What it is
A race condition is when two concurrent requests read the same stale state and both act on it. The classic shape is check-then-act (TOCTOU): both requests see "the discount code is still valid", both apply it, and the code redeems twice. There is no payload — only timing.
Why you should care
This is the class of bug every routine security check is blind to: no special character to grep for, no malicious string, nothing for a WAF to see. The second request is byte-identical to the first — and in your logs it looks like a user double-clicking a button.
Three reasons it is more common than people think:
- Unit tests never find it. Tests run sequentially. The bug exists only when two threads interleave inside a window a few milliseconds wide.
- It does not show up on a dev machine. One request at a time never opens the window. It shows up in production, where there are several instances and users who know how to click fast.
- It lives where the money is. Refunds, withdrawals, discount codes, loyalty points, balance transfers, the last seat, usage limits — anywhere a number has to go down.
And the most important difference from other topics: the fix lives in the database, not in application code. A C# if cannot be an atomic boundary across two processes. If your fix does not contain one of four things — a transaction at the right isolation level, SELECT … FOR UPDATE, a UNIQUE constraint, or a conditional UPDATE … WHERE — it has fixed nothing.
How the attack works
The window sits between the read and the write. Whatever happens in between is what the other request cannot see.
sequenceDiagram autonumber participant R1 as Request 1 participant DB participant R2 as Request 2 R1->>DB: SELECT uses_left FROM coupon WHERE code='X' DB-->>R1: 1 R2->>DB: SELECT uses_left FROM coupon WHERE code='X' Note over R1,R2: ⚠ THE WINDOW — both see 1,<br/>and neither knows the other exists. DB-->>R2: 1 R1->>DB: UPDATE coupon SET uses_left=0 R1->>DB: INSERT discount (order 1) R2->>DB: UPDATE coupon SET uses_left=0 R2->>DB: INSERT discount (order 2) Note over DB: uses_left = 0, but the code redeemed TWICE.The key point: uses_left ends up at 0, so the data looks correct. Nothing in the database says anything went wrong — which is why these bugs are usually discovered during a finance reconciliation.
Four shapes, needing four different fixes:
| Shape | Example | Fix |
|---|---|---|
| Limit overrun | A discount used twice, a withdrawal beyond balance | An atomic UPDATE … WHERE uses_left > 0 |
| Duplicate write | Two accounts with one email, two orders with one code | A UNIQUE constraint |
| Multi-step bypass | Changing the cart price mid-checkout | One transaction spanning the flow |
| Single-packet attack | 20 requests in one TCP packet | No separate fix — it only makes the window easy to hit |
That last row matters because it kills the "the window is too narrow to exploit" argument: the single-packet attack technique (James Kettle, 2023) sends 20–30 requests inside a single packet over HTTP/2, so network jitter is eliminated entirely and the requests reach the server in the same microsecond. A few-hundred-microsecond window is now a hittable window, and hittable from the public internet.
Diagram description: Sequence diagram of two requests operating on one discount code. Request 1 reads uses_left and gets 1. Request 2 also reads and also gets 1 — this is the window: both see the same stale state and neither knows the other exists. Both then UPDATE uses_left to 0 and both INSERT a discount row for their own order. The result is uses_left equal to 0, so the data looks correct, but the code redeemed twice.
Concrete example
An endpoint applying a single-use discount code. The requests are byte-identical, sent together.
# 20 parallel requests, one code. With a single-packet attack they reach the# server in the same microsecond; here curl is enough to surface the bug.for i in $(seq 1 20); do curl -s -X POST https://shop.example.com/api/orders/$i/coupon \ -H "Authorization: Bearer $TOKEN" \ -d '{"code":"SAVE50"}' &done; wait// 7 of the 20 return 200:{"applied":true,"discount":"50%","orderId":3}{"applied":true,"discount":"50%","orderId":7}{"applied":true,"discount":"50%","orderId":11}…-- And in the database, the data looks entirely normal:SELECT code, uses_left FROM coupon WHERE code = 'SAVE50';-- SAVE50 | 0 ← exactly as expected SELECT count(*) FROM order_discount WHERE coupon_code = 'SAVE50';-- 7 ← this is the number that tells the storyNot one of those 20 requests is anomalous. They simply arrived at once.
[HttpPost("/api/orders/{orderId:long}/coupon")]public async Task<IActionResult> ApplyCoupon(long orderId, [FromBody] CouponRequest req, CancellationToken ct){ // ❌ ĐỌC. Từ đây tới lúc GHI ở dòng 20 là cửa sổ, và mọi request khác tới trong // khoảng đó cũng đọc được đúng giá trị này. var coupon = await _db.Coupons .FirstOrDefaultAsync(c => c.Code == req.Code, ct); if (coupon is null) return NotFound(); // ❌ KIỂM. Đúng về logic, và vô dụng về đồng thời: hai mươi request cùng lúc // đều thấy UsesLeft = 1 và đều đi qua dòng này. if (coupon.UsesLeft <= 0) return BadRequest(new { error = "Coupon exhausted" }); coupon.UsesLeft--; _db.OrderDiscounts.Add(new OrderDiscount(orderId, req.Code, coupon.Percent)); // ❌ GHI, ở một round-trip KHÁC. Transaction của SaveChanges không cứu được: // mặc định là Read Committed, và Read Committed cho phép đúng cửa sổ này. await _db.SaveChangesAsync(ct); return Ok(new { applied = true, discount = $"{coupon.Percent}%" });}@app.post("/api/accounts/<int:account_id>/withdraw")def withdraw(account_id): amount = Decimal(request.json["amount"]) with db.session.begin(): # ❌ ĐỌC. Mặc định của Postgres là Read Committed, và Read Committed cho # phép một transaction khác đọc cùng giá trị này. acct = db.session.get(Account, account_id) # ❌ KIỂM. Hai request đồng thời đều thấy balance = 100 và đều đi qua. if acct.balance < amount: return {"error": "insufficient funds"}, 400 # ❌ GHI. Gán từ một giá trị đã ĐỌC trước đó: nó ghi đè kết quả của # transaction kia thay vì trừ tiếp lên nó. acct.balance = acct.balance - amount db.session.add(Ledger(account_id=account_id, amount=-amount)) return {"balance": str(acct.balance)}What happened in the wild
The DAO, June 2016 — ~3.6 million ETH (~$50m at the time). The splitDAO function sent funds before updating the balance, so a re-entrant contract could withdraw repeatedly against the same balance. This is a race condition in its purest form: "act then write" instead of "write then act". The fallout was large enough that Ethereum hard-forked, splitting into ETH and ETC.
Starbucks gift cards, 2015 (Egor Homakov). Transferring a balance between two cards concurrently created money from nothing — the limit-overrun shape from block 3. Worth reading because it needed no technique: two browser tabs and a bit of timing.
The single-packet attack technique (James Kettle, PortSwigger, 2023) is not an incident, but it changed the risk level of this whole family: it turned windows previously dismissed as "too narrow to exploit over the internet" into reliably exploitable ones.
How to defend
One atomic statement — check and write in the same `UPDATE`
mandatoryThis is the correct fix for the limit overrun shape, and the best one because it removes the window rather than guarding it.
-- The condition lives INSIDE the UPDATE. The database guarantees two transactions-- cannot both see uses_left > 0 and both write: the second sees 0 and matches 0 rows.UPDATE coupon SET uses_left = uses_left - 1 WHERE code = @code AND uses_left > 0RETURNING uses_left;Then check the affected row count: zero rows means the code is spent, and that is the answer to the request. That check is the commonly-skipped part — an ExecuteUpdate whose return value nobody reads is a fix that does not work.
In EF Core 8: ExecuteUpdateAsync emits exactly one UPDATE … WHERE and returns the row count. Do not use context.Coupons.First(), mutate a property, then SaveChanges() — that is check-then-act across two round trips.
/// <summary>/// Hai bảo đảm, không phải một, vì chúng bảo vệ hai thứ khác nhau:////// • UPDATE … WHERE uses_left > 0 → bộ đếm không xuống dưới 0 (limit overrun)/// • UNIQUE (coupon_code, order_id) → không có hai dòng giảm giá cho cùng cặp////// Chỉ có cái đầu thì hai request cho CÙNG một đơn vẫn tạo hai dòng discount./// Chỉ có cái sau thì hai request cho HAI đơn khác nhau vẫn vượt max_uses./// </summary>public sealed class CouponService{ private readonly SecLabDbContext _db; public CouponService(SecLabDbContext db) => _db = db; public async Task<CouponResult> ApplyAsync(long orderId, string code, CancellationToken ct) { // Transaction bao cả hai bước: nếu INSERT vi phạm UNIQUE thì việc trừ bộ đếm // cũng phải mất theo, nếu không mã bị "tiêu" mà không ai được giảm giá. await using var tx = await _db.Database.BeginTransactionAsync(ct); // MỘT câu lệnh: điều kiện nằm TRONG UPDATE. Không có khoảng nào giữa kiểm và // ghi để một request khác chen vào — DB bảo đảm điều đó, không phải code này. // // ExecuteUpdateAsync sinh đúng một câu UPDATE … WHERE và KHÔNG nạp entity nào, // nên không có bản sao nào trong bộ nhớ để ai đó sửa rồi SaveChanges. var claimed = await _db.Coupons .Where(c => c.Code == code && c.UsesLeft > 0) .ExecuteUpdateAsync(s => s.SetProperty(c => c.UsesLeft, c => c.UsesLeft - 1), ct); // ĐỌC SỐ HÀNG. Đây là dòng hay bị bỏ, và bỏ nó thì cả bản vá trên vô nghĩa: // câu UPDATE đúng, rồi ta bỏ đi câu trả lời của nó và vẫn trả 200. if (claimed == 0) { await tx.RollbackAsync(ct); return CouponResult.Exhausted; } try { _db.OrderDiscounts.Add(new OrderDiscount(orderId, code)); await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); return CouponResult.Applied; } catch (DbUpdateException e) when (IsUniqueViolation(e)) { // Ràng buộc DB là nguồn sự thật, và cách nó nói "trùng" là ném lỗi. Đây là // chỗ duy nhất trong codebase mà bắt exception làm luồng điều khiển là ĐÚNG: // không có cách nào hỏi trước mà không quay lại kiểm-rồi-dùng. await tx.RollbackAsync(ct); return CouponResult.AlreadyApplied; } } /// <summary>23505 = unique_violation. Mã của chuẩn SQL, không phải của Npgsql.</summary> private static bool IsUniqueViolation(DbUpdateException e) => e.InnerException is PostgresException { SqlState: "23505" };} // ── Migration: ràng buộc là một nửa của bản vá, và nó sống ở đây ─────────────// Nửa này nằm trong migration chứ không trong code, nên nó có thể bị một migration// khác xoá đi mà không ai thấy — đó là lý do khối 7 có một phép kiểm riêng cho nó.migrationBuilder.Sql(""" ALTER TABLE core.order_discount ADD CONSTRAINT ux_order_discount_order_coupon UNIQUE (order_id, coupon_code); ALTER TABLE core.coupon ADD CONSTRAINT ck_coupon_uses_left_nonneg CHECK (uses_left >= 0); """);from decimal import Decimal from sqlalchemy import text class InsufficientFunds(Exception): pass def withdraw(account_id: int, amount: Decimal) -> Decimal: """Một câu lệnh nguyên tử. Điều kiện nằm TRONG UPDATE, không trong một if. `balance = balance - :amt` trừ trên giá trị HIỆN TẠI trong DB, không trên giá trị ta đã đọc trước đó — nên hai transaction đồng thời cộng dồn đúng thay vì ghi đè kết quả của nhau. Đây là khác biệt giữa `SET x = x - 1` và `SET x = <đã đọc> - 1`, và nó là toàn bộ bản vá. """ with db.session.begin(): row = db.session.execute( text(""" UPDATE account SET balance = balance - :amt WHERE id = :id AND balance >= :amt RETURNING balance """), {"id": account_id, "amt": amount}, ).first() # ĐỌC KẾT QUẢ. None nghĩa là điều kiện không khớp — số dư không đủ. Bỏ dòng # này đi thì câu UPDATE vẫn đúng và endpoint vẫn trả 200 cho mọi request. if row is None: raise InsufficientFunds() db.session.add(Ledger(account_id=account_id, amount=-amount)) return row.balance # Và lưới cuối ở tầng DB. Nó không thay bản vá trên — nó bắt câu truy vấn TIẾP THEO,# do người khác viết, ở một endpoint mà trang này chưa tồn tại lúc họ viết nó.MIGRATION = """ ALTER TABLE account ADD CONSTRAINT ck_account_balance_nonneg CHECK (balance >= 0); -- Idempotency: một Idempotency-Key chỉ tạo được một lệnh, nên retry của client -- không thành một lần rút thứ hai. CREATE UNIQUE INDEX ux_ledger_idempotency ON ledger (idempotency_key) WHERE idempotency_key IS NOT NULL;"""`UNIQUE` constraints — for duplicate writes, and only the DB can guarantee it
mandatoryFor the duplicate write shape (two accounts with one email, one code applied to two orders), no application-level if is enough: if (!await _users.AnyAsync(...)) then Add is check-then-act.
-- A unique index: each code redeems once across the whole system.CREATE UNIQUE INDEX ux_order_discount_coupon ON core.order_discount (coupon_code); -- Or per user: once each.CREATE UNIQUE INDEX ux_order_discount_coupon_user ON core.order_discount (coupon_code, user_id);Then catch the constraint violation and turn it into a business answer, not a 500. In Npgsql: a PostgresException with SqlState == "23505". This is the one place where exceptions as control flow are right: the constraint is the source of truth, and the way it says "duplicate" is by throwing.
An idempotency key is the same technique for a different purpose: the client sends Idempotency-Key, the server has a UNIQUE on that column, so a retry of one command produces no second effect.
Row locking when the flow genuinely needs several steps
When the logic will not fit in one statement (pricing across several tables before debiting a balance), lock the row inside a transaction:
BEGIN;-- FOR UPDATE holds the row until commit. The second transaction WAITS here,-- and when it proceeds it sees the UPDATED state, not the stale one.SELECT balance FROM account WHERE id = @id FOR UPDATE;-- … multi-step calculation …UPDATE account SET balance = balance - @amount WHERE id = @id;COMMIT;Two requirements, both commonly skipped:
- Lock in a consistent order when locking several rows (always ascending id). Without it, two transfers in opposite directions deadlock.
- A lock timeout (
SET LOCAL lock_timeout = '3s'). A lock without a timeout turns a hot row into a system-wide bottleneck, and that is a DoS with no vulnerability required.
Serializable isolation is the alternative: the database detects the conflict and aborts one side. It is correct and simpler, but the caller must have a retry loop — otherwise load becomes 500s.
Rate limit per resource, not only per IP
This layer does not fix the bug — it narrows the window and caps the attempts. But it has to be aimed right: per-IP rate limiting does nothing against 20 requests from one IP inside one packet.
What is needed is a lock on the contended resource: "code SAVE50 is processed by one flow at a time". A distributed lock in Redis (SET key NX PX 5000) keyed on coupon:{code} does that.
Important: a Redis lock does not replace the database constraint. It is an optimisation (fewer aborted transactions), not a guarantee — Redis can lose the lock on failover, and at that moment only layer 1 is left.
Detection: periodic reconciliation, and rate-based alerting
Block 3 makes the point that after the bug fires, the data looks correct. So detection has to be a reconciliation comparing two numbers that should agree:
-- Nightly. Every row returned is a race condition that already happened.SELECT c.code, c.max_uses, count(d.*) AS actual FROM core.coupon c JOIN core.order_discount d ON d.coupon_code = c.code GROUP BY c.code, c.max_usesHAVING count(d.*) > c.max_uses;And alert on rate, not total: N requests for the same coupon_code within one second from one account is the signature of an attempt, and it is a low-noise signal. Layer 3 because it blocks nothing — but for this family it is the only thing that tells you the bug fired, because the database will not.
Verifying the fix
This is the topic where sequential tests are useless, and that is the single most important thing to remember about verifying it. A test that calls the endpoint twice in a row passes against the broken code.
1. A real concurrent test — N threads, one Barrier. The Barrier is the deciding detail: it holds every thread until all are ready, then releases them together. Without it, the first thread finishes before the last starts and the window never opens. See the csharp / test tab.
2. Assert the right number. The test must check how many effects occurred (count(*) in the consequence table), not the counter's final state — because uses_left = 0 is true in both cases. This mistake is what makes most race-condition tests meaningless.
3. Run that test many times in CI. One pass proves nothing: a race is probabilistic. dotnet test --filter Race -- xunit.execution.Repeat=50, or a loop inside the test itself. And on multiple cores:
for i in $(seq 1 30); do dotnet test --filter Concurrency --nologo -v q || { echo "failed on run $i"; exit 1; }done4. Check the database constraint actually exists — the layer-1b fix lives in a migration, not in code, so another migration can drop it with nobody noticing:
-- Must return exactly one row:SELECT indexname FROM pg_indexes WHERE schemaname = 'core' AND tablename = 'order_discount' AND indexdef ILIKE '%unique%coupon_code%';5. Try a single-packet attack against staging. Burp Repeater ("send group in parallel") or turbo-intruder. This is the only check that reproduces the real conditions, and it is what separates "narrow window" from "window closed".
6. Run the layer-3 reconciliation query in CI against test data, not only from a runbook. It is a business invariant, and invariants deserve tests.
public class CouponConcurrencyTests : IClassFixture<PostgresFixture>{ private readonly PostgresFixture _fx; public CouponConcurrencyTests(PostgresFixture fx) => _fx = fx; /// <summary> /// Test PHẢI song song thật. Một test gọi endpoint hai lần liên tiếp pass trên /// code lỗi — cửa sổ không bao giờ mở, vì lần gọi đầu đã ghi xong trước khi lần /// thứ hai bắt đầu đọc. /// /// Barrier là thứ làm test này có nghĩa: nó giữ cả 20 task ở vạch xuất phát cho /// tới khi task cuối cùng đã sẵn sàng, rồi thả tất cả trong cùng một nhịp. Đây là /// bản mô phỏng của single-packet attack ở tầng test. /// </summary> [Theory] [InlineData(20)] [InlineData(50)] public async Task Single_use_coupon_applies_exactly_once(int concurrency) { await _fx.SeedCouponAsync("SAVE50", maxUses: 1); var orders = await _fx.SeedOrdersAsync(count: concurrency); using var barrier = new Barrier(concurrency); var results = new CouponResult[concurrency]; await Task.WhenAll(Enumerable.Range(0, concurrency).Select(i => Task.Run(async () => { // Mỗi task có scope DI riêng → connection riêng → transaction riêng. // Dùng chung một DbContext thì test đo sai thứ: nó đo hành vi của // change tracker, không đo hành vi đồng thời của DB. await using var scope = _fx.Services.CreateAsyncScope(); var svc = scope.ServiceProvider.GetRequiredService<CouponService>(); barrier.SignalAndWait(); // ← tất cả xuất phát cùng lúc results[i] = await svc.ApplyAsync(orders[i].Id, "SAVE50", default); }))); // Khẳng định 1: đúng MỘT request thành công. Assert.Equal(1, results.Count(r => r == CouponResult.Applied)); // Khẳng định 2 — QUAN TRỌNG NHẤT: đếm TÁC DỤNG, không đếm bộ đếm. // // uses_left = 0 là ĐÚNG trong cả hai trường hợp (đã vá và chưa vá), nên một // test chỉ kiểm uses_left sẽ xanh trên code lỗi. Con số nói ra sự thật là số // dòng discount thật sự đã được ghi. Assert.Equal(1, await _fx.CountDiscountsAsync("SAVE50")); } /// <summary> /// Mã cho phép 5 lượt: 50 request đồng thời phải cho ra ĐÚNG 5. Test này bắt được /// một lỗi mà test max_uses=1 ở trên bỏ qua — bản vá đúng cho 1 nhưng sai dấu /// so sánh (>= thay vì >) vẫn pass ở trên và đỏ ở đây. /// </summary> [Fact] public async Task Multi_use_coupon_never_exceeds_its_limit() { await _fx.SeedCouponAsync("SAVE10", maxUses: 5); var orders = await _fx.SeedOrdersAsync(count: 50); using var barrier = new Barrier(50); await Task.WhenAll(orders.Select((o, i) => Task.Run(async () => { await using var scope = _fx.Services.CreateAsyncScope(); var svc = scope.ServiceProvider.GetRequiredService<CouponService>(); barrier.SignalAndWait(); await svc.ApplyAsync(o.Id, "SAVE10", default); }))); Assert.Equal(5, await _fx.CountDiscountsAsync("SAVE10")); Assert.Equal(0, await _fx.GetUsesLeftAsync("SAVE10")); } /// <summary> /// Ràng buộc UNIQUE sống trong migration, không trong code — nên một migration /// khác xoá nó đi mà không test nào đỏ. Test này là thứ làm nó đỏ. /// </summary> [Fact] public async Task Unique_constraint_exists() { var found = await _fx.QueryScalarAsync<long>(""" SELECT count(*) FROM pg_indexes WHERE schemaname = 'core' AND tablename = 'order_discount' AND indexdef ILIKE '%UNIQUE%coupon_code%' """); Assert.True(found > 0, "ràng buộc UNIQUE trên order_discount đã biến mất"); }}Common mistakes
| The "fix" | Why it is wrong |
|---|---|
if (coupon.UsesLeft > 0) { coupon.UsesLeft--; SaveChanges(); } | Check-then-act across two round trips. This is the broken code, written with an ORM |
lock / Monitor / SemaphoreSlim in C# | Locks within ONE process. Two pods means two independent locks, and the fix disappears the moment you scale out |
A transaction, still at Read Committed | Read Committed permits exactly the block 3 window. A transaction alone is not the fix — you need FOR UPDATE or the condition inside the UPDATE |
ExecuteUpdateAsync without reading the row count | The right atomic statement, then discarding its answer. The second request still gets a 200 |
| Per-IP rate limiting | 20 requests in one packet come from one IP. That is legitimate behaviour under any sane rate limit |
| Debouncing in the client | The attacker is not using your UI |
| A Redis lock alone | Redis loses locks on failover. It is an optimisation, not a guarantee — the DB constraint must still be there |
Retrying on Serializable aborts — but with no retry written | Turns a business outcome into 500s under load |
| A test calling the endpoint twice in a row | Passes against the broken code. The window never opens |
The most common severity mistake: "the window is only milliseconds, it is not exploitable over the internet". The single-packet attack settled that in 2023.
The scoping mistake: fixing only the reported endpoint. The same check-then-act shape appears almost everywhere a number has to go down — refunds, loyalty points, usage limits, remaining seats. The way to find them is to grep for tables with columns like *_left, *_remaining, balance, quota.
Comments
Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.
You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.
Loading comments…