In multi-step booking flows, the gap between slot selection and payment completion can span several minutes of form-filling. Without a temporary hold during that window, a second user can select the same slot, complete the flow first, and the original user hits a conflict error at the payment step — after they've already entered card details. CWE-362 describes this as a classic TOCTOU (time-of-check to time-of-use) window. The user experience failure is severe: you've collected full intent and payment details from a customer only to tell them at the last step that the slot is gone.
Low because the primary impact is user experience degradation (failed checkout at the final step) rather than data corruption or security exposure.
Implement a Redis key with a TTL or a database row with status='HELD' and an expiresAt timestamp when the user selects a slot. Add this in your slot-selection handler (e.g., src/app/api/bookings/hold/route.ts).
// Redis-based hold — 5-minute TTL, no-overwrite (NX)
await redis.set(`booking:hold:${slotId}:${userId}`, '1', 'NX', 'EX', 300);
// Or: database hold record
await db.bookingHold.create({
data: { slotId, userId, expiresAt: addMinutes(new Date(), 5) }
});
Also add a cleanup job or TTL-expiry mechanism so abandoned holds don't block slots indefinitely.
ID: booking-flow-lifecycle.booking-creation.time-slot-lock
Label: Temporary hold on time slot
Severity: low
What to look for: For multi-step booking flows (e.g., fill details then review then pay), check for a mechanism that reserves the slot while the user is completing the flow. Count all hold mechanisms: Redis keys with TTL (e.g., redis.set(..., 'NX', 'EX', 300)), database records with status='HELD' and expiresAt, or temporary reservation tables. Also check for a cleanup mechanism (cron job, TTL expiry, or scheduled task) that releases expired holds. The hold TTL must be no more than 15 minutes.
Pass criteria: A temporary hold or lock is placed on the slot when the user starts the booking to prevent others from selecting it. The hold expires (typically 5-10 minutes, no more than 15 minutes) if the booking is not completed. At least 1 hold mechanism with automatic expiry must exist. Report even on pass: "Hold mechanism: [type] with [N]-minute TTL in [file:line]."
Fail criteria: The slot remains "available" in queries until the final booking confirmation, increasing race condition risk and user frustration (user selects slot, fills form, discovers slot taken at checkout). Also fails if a hold exists but has no expiry mechanism (holds persist indefinitely).
Skip (N/A) when: Booking is a single-step (instant click-to-book) with no intermediate states — the entire flow completes in 1 API call with no user-facing review or payment step.
Detail on fail: "No temporary hold mechanism found. Two users can start booking the same slot simultaneously, and the slower one fails at checkout."
Cross-reference: The concurrent-request-handling check in Conflict Prevention covers database-level constraints that complement temporary holds.
Cross-reference: The failure-handling check in Payment Integration verifies that failed payments release held slots.
Cross-reference: The overbooking-prevention check in this category verifies capacity limits that interact with held slots.
Remediation: Implement a temporary hold using Redis or database status. Add this in your slot selection handler (e.g., src/app/api/bookings/hold/route.ts or src/lib/booking-service.ts).
// Redis-based hold (TTL = 5 minutes)
await redis.set(`booking:hold:${slotId}`, userId, 'EX', 300);
// Or database-based (status = 'HELD')
await db.booking.create({
data: { slotId, status: 'HELD', expiresAt: addMinutes(now(), 5) }
});