The coupon endpoint did the sensible thing in the wrong order: it checked whether the coupon had been used, then — some milliseconds later — marked it used. Between those two steps lived a window, and windows are for climbing through.
The window
The check and the write were not atomic. Fire two requests close enough together and both pass the “has this been redeemed?” test before either performs the “mark redeemed” write. Both proceed. The coupon is applied twice.
The math of it
If a single redemption’s check-to-write gap is and you fire requests within a burst of width , the expected number that land inside the same window is roughly
so the attack is not “get lucky once” — it is “reduce until winning is the default.” A tight burst over HTTP/2 made small enough that two-for-one was reliable, not lucky.
The fix
Make the decrement atomic and conditional: a single UPDATE ... WHERE remaining > 0 (or a unique constraint on (coupon, user)) collapses check-and-write into one indivisible step. Everything softer than that is just a slower race. It was staging. It was still embarrassing for someone.