async function redeem(userId: string, code: string) {
const coupon = await db.coupon.findUnique({ where: { code } });
if (!coupon || coupon.usedAt) throw new Error("Invalid coupon");
await db.wallet.update({ where: { userId }, data: { balance: { increment: coupon.value } } });
await db.coupon.update({ where: { code }, data: { usedAt: new Date(), usedBy: userId } });
}
The attacker probably gets the credit many times. All 20 requests read the coupon before any of them marks it as used. So each one passes the check and adds $50. This is a race condition called TOCTOU (time of check to time of use). Attack tools can send many requests so they arrive at almost the same moment.
Fix it by making the check and the change one atomic step in the database:
- Conditional update: run
UPDATE coupons SET used_at = now(), used_by = $1 WHERE code = $2 AND used_at IS NULL. Continue only if exactly one row changed. In Prisma,updateManywithwhere: { code, usedAt: null }returns that count. - Unique constraint: insert into a
redemptionstable with a unique key on the coupon id. The database rejects the second insert. - Row lock: inside a transaction, read the coupon with
SELECT ... FOR UPDATE. Other requests wait until the first one commits.
Also wrap marking the coupon and adding the credit in one transaction, so a crash can't leave only half done. A lock in the app's memory doesn't work once you run more than one instance.
The same bug appears in withdrawals, stock counts, likes and invite limits.
- Concurrent requests all pass the check before any of them writes, so the coupon is used many times.
- This race is called time of check to time of use (TOCTOU).
- Make check and change atomic with a conditional update, a unique constraint or a row lock.
- Do the coupon update and the credit in one transaction.
- In-memory locks fail when the app runs on several instances.