← Security
Technique API, workflow, and abuse resistance

Race conditions and limit overruns

Two valid requests can break one shared limit.

A promotion is configured for one redemption. Two customers complete checkout at nearly the same time, and both receipts show the discount. Each request looked valid when it checked the coupon. We’ll find out whether the limit was enforced at the shared state change or only before it.

The skill to keep

Whenever code checks a limit and then changes state, ask whether another request can pass the same check before the first change is visible.

TypeScriptGo One last coupon · concurrent requests · an atomic reservation
01 / Read the report

Two successful receipts do not yet tell us why the limit failed.

At 09:03, two orders apply the “FIRST-ORDER” coupon. Its capacity is one redemption. The order service says each request checked availability and succeeded. That points toward a race, but we still need to rule out duplicate accounting, a delayed dashboard, or a retry that created two receipts for one order.

Compare order IDs, request IDs, coupon redemption rows, and the capacity counter. Confirm whether the two orders are distinct and whether the database rows committed. Use a disposable database to reproduce; do not test concurrency by submitting live orders.

Case file / Checkout promotionOne remaining coupon slot appears on two completed orders.
Asset
Promotion budget and the customer’s order price.
Concurrent actors
Two independent checkout requests, possibly served by different workers.
Shared state
Coupon capacity and the durable redemption records.
Invariant
Committed redemptions never exceed configured capacity.
01 / ReadRemaining capacity

Both requests may observe one slot.

02 / Decide“Still available”

Each decision can be locally correct.

03 / WriteRecord redemption

The shared state must arbitrate.

04 / CommitOrder + discount

The durable result must preserve the limit.

A race condition occurs when a result depends on the timing or ordering of concurrent operations. The common shape here is check then act: read capacity, decide there is room, then record the redemption. Each request can pass its check before either write becomes visible.

Leave with: the shared value, the limit, and the state change that must happen at most once per available slot.
02 / Separate the causes

Establish two distinct committed orders before calling it a race.

Possible explanations include two requests reading the same old count, a retry creating a second redemption, a unique-order constraint missing from storage, or a reporting job counting an uncommitted or duplicate event. These causes need different repairs. Idempotency prevents repeating the same logical operation; an atomic capacity check prevents distinct requests from consuming more than the shared limit.

Hold the coupon and capacity constant. Compare the two order IDs and idempotency keys, then inspect the committed redemption rows and timestamps. In a disposable fixture, add a barrier after both reads so each request is known to have observed the last slot before either write proceeds.

Compare your diagnosis with the case fileReveal after choosing a discriminating check
Diagnostic checkpointMake the competing requests share one observed state.
Observation
Capacity is one, and two committed redemptions reference distinct orders.
Competing causes
Two check-then-act requests overlapped; one order was duplicated by retry; or reporting duplicated a row.
Discriminating check
Verify unique order IDs and persisted rows. In a fixture, coordinate two requests so both complete the availability read before either records a redemption; then count committed redemptions.
Conclusion if confirmed
If each request observed zero prior redemptions and both later committed distinct rows, the check and capacity change were not atomic. This confirms the limit bug for that operation, not every coupon or checkout path.
Checkpoint: establish distinct logical operations and a shared stale read before choosing the repair.
03 / Trace check then act

The gap between the read and write is the race window.

The intentionally flawed examples read a coupon, compare redeemed with capacity, and only then insert a redemption. Request A and request B can both read 0 / 1, both decide to proceed, and both write. The comparison is not wrong for either snapshot; the operation is incomplete because it does not reserve the slot.

Trace the check-then-act path in TypeScript and Go.

The database calls are represented by a store boundary in the flawed path.

TypeScriptIntentionally flawed · isolated example
redemption.ts · check then act
// Intentionally flawed: the read, decision, and write are separate operations.
export async function redeemVulnerable(
	store: CouponStore,
	code: string,
	orderID: string
): Promise<RedemptionResult> {
	const coupon = await store.read(code);
	if (!coupon || coupon.redeemed >= coupon.capacity) return 'sold-out';
	await store.insertRedemption(code, orderID);
	return 'redeemed';
}
GoIntentionally flawed · isolated example
redemption.go · check then act
// Intentionally flawed: a concurrent request can pass the same read check.
type Store interface {
	ReadCoupon(ctx context.Context, code string) (Coupon, bool, error)
	InsertRedemption(ctx context.Context, code, orderID string) error
}

func RedeemVulnerable(ctx context.Context, store Store, code, orderID string) error {
	coupon, found, err := store.ReadCoupon(ctx, code)
	if err != nil {
		return err
	}
	if !found || coupon.Redeemed >= coupon.Capacity {
		return ErrSoldOut
	}
	return store.InsertRedemption(ctx, code, orderID)
}
A / Readredeemed = 0

Capacity = 1, so A proceeds.

B / Readredeemed = 0

B sees the same available slot.

A + B / WriteTwo redemptions

Both acted on a stale decision.

Result2 > capacity 1

The invariant is broken.

Leave with: the interleaving that produces two writes from one available slot.
04 / Make capacity atomic

Let the shared store decide and reserve in one operation.

Move the capacity predicate into the state change. A conditional update increments the counter only while it is below capacity. The application checks that exactly one row changed; if none did, the coupon is exhausted. Insert the redemption record in the same transaction so a failed insert rolls back the counter update.

The transaction must run against the same database connection and isolation semantics as the update. A process-local mutex cannot coordinate other servers. Dialects vary in placeholder syntax and locking behavior, so confirm the chosen database’s documented behavior and retry serialization or deadlock failures only under a bounded policy.

Compare the repaired operation in both languages.

Go shows the transaction body; TypeScript calls a repository operation whose contract is the same atomic transaction.

TypeScriptRepaired · reserve and record atomically
redemption.ts · atomic store boundary
export async function redeemSafely(
	store: CouponStore,
	code: string,
	orderID: string
): Promise<RedemptionResult> {
	return store.redeemAtomically(code, orderID);
}
GoRepaired · reserve and record atomically
redemption.go · conditional update + transaction
// The conditional update reserves one slot; the insert and update commit together.
func RedeemSafely(ctx context.Context, db *sql.DB, code, orderID string) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()

	result, err := tx.ExecContext(ctx,
		`UPDATE coupons
		 SET redeemed_count = redeemed_count + 1
		 WHERE code = ? AND redeemed_count < capacity`,
		code,
	)
	if err != nil {
		return err
	}
	changed, err := result.RowsAffected()
	if err != nil {
		return err
	}
	if changed != 1 {
		return ErrSoldOut
	}
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO coupon_redemptions (code, order_id) VALUES (?, ?)`, code, orderID,
	); err != nil {
		return fmt.Errorf("record redemption: %w", err)
	}
	return tx.Commit()
}
atomic reservation · SQL sketch
BEGIN;
UPDATE coupons
SET redeemed_count = redeemed_count + 1
WHERE code = ? AND redeemed_count < capacity;
-- Continue only when exactly one row changed.
INSERT INTO coupon_redemptions (code, order_id) VALUES (?, ?);
COMMIT;
-- If the update changed zero rows or the insert fails, roll back.
Review the complete isolated examplesTransaction code and store contract
redemption.ts · full example
export type Coupon = { code: string; capacity: number; redeemed: number };
export type RedemptionResult = 'redeemed' | 'sold-out';

export interface CouponStore {
	read(code: string): Promise<Coupon | null>;
	insertRedemption(code: string, orderID: string): Promise<void>;
	/** Atomically reserves capacity and records the redemption in one transaction. */
	redeemAtomically(code: string, orderID: string): Promise<RedemptionResult>;
}

// Intentionally flawed: the read, decision, and write are separate operations.
export async function redeemVulnerable(
	store: CouponStore,
	code: string,
	orderID: string
): Promise<RedemptionResult> {
	const coupon = await store.read(code);
	if (!coupon || coupon.redeemed >= coupon.capacity) return 'sold-out';
	await store.insertRedemption(code, orderID);
	return 'redeemed';
}

export async function redeemSafely(
	store: CouponStore,
	code: string,
	orderID: string
): Promise<RedemptionResult> {
	return store.redeemAtomically(code, orderID);
}
redemption.go · full example
package redemption

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
)

var ErrSoldOut = errors.New("coupon sold out")

type Coupon struct {
	Code     string
	Capacity int64
	Redeemed int64
}

// Intentionally flawed: a concurrent request can pass the same read check.
type Store interface {
	ReadCoupon(ctx context.Context, code string) (Coupon, bool, error)
	InsertRedemption(ctx context.Context, code, orderID string) error
}

func RedeemVulnerable(ctx context.Context, store Store, code, orderID string) error {
	coupon, found, err := store.ReadCoupon(ctx, code)
	if err != nil {
		return err
	}
	if !found || coupon.Redeemed >= coupon.Capacity {
		return ErrSoldOut
	}
	return store.InsertRedemption(ctx, code, orderID)
}


// The conditional update reserves one slot; the insert and update commit together.
func RedeemSafely(ctx context.Context, db *sql.DB, code, orderID string) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()

	result, err := tx.ExecContext(ctx,
		`UPDATE coupons
		 SET redeemed_count = redeemed_count + 1
		 WHERE code = ? AND redeemed_count < capacity`,
		code,
	)
	if err != nil {
		return err
	}
	changed, err := result.RowsAffected()
	if err != nil {
		return err
	}
	if changed != 1 {
		return ErrSoldOut
	}
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO coupon_redemptions (code, order_id) VALUES (?, ?)`, code, orderID,
	); err != nil {
		return fmt.Errorf("record redemption: %w", err)
	}
	return tx.Commit()
}

Leave with: a shared-state operation that claims no more slots than remain and commits its related record together.
05 / Prove the invariant

Send competing requests and count committed state.

A sequential test can pass while the race remains. Coordinate several requests to contend for a capacity of one, then inspect the committed counter and redemption rows. Assert exactly one success, no more than one redemption, and no order with a discount but no redemption record. Repeat with capacity greater than one and with a duplicate retry of the same order.

Test the database you deploy. An in-memory mock can confirm that the handler calls a method, but it cannot prove transaction isolation or conditional-update behavior. Check how the driver reports affected rows, how conflicts surface, and whether commit failures are returned to the caller.

01 / Capacity oneMany concurrent orders

At most one transaction reserves the slot.

02 / Capacity NContend for N slots

Success count never exceeds N.

03 / Same order retryRepeat idempotency key

One logical order is recorded once.

04 / Failed insertRollback the reservation

Counter and redemption rows stay consistent.

Leave with: concurrent evidence that committed usage never exceeds the configured capacity.
06 / Make the next call

Choose a control that protects shared state across workers.

Try a decision

Two checkout workers can redeem the last slot. What should change?

Both requests can pass the current read check. The service runs three replicas, and customers may retry checkout when a response is delayed. Which control protects the coupon limit across replicas?

Your next move

The example establishes a limit overrun when concurrent requests read the same available capacity and both commit a redemption. The resulting impact depends on the product: a coupon may reduce revenue, a quota may grant excess usage, inventory may oversell, and a balance bug may move value. Do not infer one of those consequences from another; trace the protected state and the operation that changes it.

OWASP’s Business Logic Security Cheat Sheet describes check-then-act races in sensitive operations. The Go documentation explains transaction boundaries and commit/rollback. Checked 2026-10-01; database isolation and SQL details remain engine-specific.

Connections to follow nextRelated lessons

Idempotency and replay resistance helps make retries of one logical action safe. Time-of-check/time-of-use authorization applies the same timing question to permission decisions.

Question to keep: can two requests both pass this check before either request changes the shared state?