ASVS 5.0 V2.3.4 requires business logic level locking so that limited quantity resources, such as theater seats or delivery slots, cannot be double booked by manipulating the application's logic. It is a Level 2 requirement.
Why it matters
Two people click Buy on the last seat at the same moment. If the code reads availability, then writes, both reads see one seat free and both writes succeed. Attackers use this deliberately: they send dozens of parallel requests to claim stock, win limited drops, reuse a single use voucher, or spend one balance twice. This is called a race condition, and it is easy to miss because everything works in single user testing.
How to implement it
Let the database enforce exclusivity. The strongest option is a unique constraint, because no code path can bypass it.
CREATE TABLE seat_booking (
event_id bigint NOT NULL,
seat_no int NOT NULL,
user_id bigint NOT NULL,
PRIMARY KEY (event_id, seat_no)
);
A second insert for the same seat fails with a constraint error, which you turn into a friendly "seat taken" response.
For counted stock, use an atomic conditional update:
UPDATE inventory SET quantity = quantity - 1
WHERE sku = :sku AND quantity > 0;
Check the number of rows changed. Zero means sold out. When you must read and then decide, take a row lock with SELECT ... FOR UPDATE inside a transaction. For temporary holds, store an expiry time and release expired holds with a scheduled job, so abandoned carts do not block inventory forever.
Do not use an application level lock held in one process memory when you run several instances. If you need a distributed lock, use one with expiry and a fencing token, and still keep the database constraint as the final guard.
How to test it
Write a test that fires 50 parallel requests for a single seat, each from a different test account. Exactly one should succeed. Repeat for voucher redemption and balance spend. Run it repeatedly, since races appear intermittently. Also test the release path: cancel a booking and confirm the seat becomes available once.
Holds, expiry and fairness
Real systems rarely book instantly. The user picks a seat, then pays. Model this with a hold that has an owner and an expiry time. The row for a held seat carries the user id and a held_until timestamp. Booking succeeds only for that user before expiry, and a cleanup job or a conditional query frees expired holds.
UPDATE seat SET held_by = :user, held_until = now() + interval '10 minutes'
WHERE event_id = :event AND seat_no = :seat
AND (held_by IS NULL OR held_until < now());
Zero changed rows means someone else has it. Cap how many holds one account can have at once, or one script will reserve everything and release it later. That limit is the anti automation control from V2.4.1 at work.
Idempotent payments
Payment retries must not create two bookings. Use an idempotency key, and make the booking row the single source of truth. If a payment webhook arrives twice, the second call finds the booking already confirmed and does nothing.
What to log
Log hold, confirm, release and expiry events with user and resource ids. When someone reports a double booking, those records let you tell a code bug from deliberate abuse.
Common mistakes
- Read, check, then write as separate statements outside a transaction.
- Relying on a lock held only in application memory.
- Locks without a timeout, which freeze the resource when a request dies.
- Testing only with sequential requests.
Quick check
Why is a unique constraint better than a check in code?
The database applies it atomically for every writer, including code paths and scripts that forget the check.
How do you check that stock never goes negative?
Use a conditional update with a quantity greater than zero condition, and test with parallel purchases.