Learn how CampusBite uses OTP verification to secure order pickups and prevent fraud.
CampusBite implements a secure OTP (One-Time Password) system for order pickup verification. When an order is ready, a 6-digit OTP is generated and sent to the customer, who must share it with the store employee to collect their food.
Prevents unauthorized pickups: Only the customer who placed the order has the OTP
Confirms correct order: Store employees verify they’re handing food to the right person
Tracks actual pickups: System knows exactly when an order was collected
Reduces fraud: Makes it nearly impossible to claim someone else’s order
OTP verification is required for all order pickups. Store employees cannot mark an order as picked up without either verifying the OTP or manually confirming verification.
export const updateOrderStatus = async (req, res, next) => { try { const { id } = req.params; const userId = req.user.id; const { status } = req.body; const order = await Order.findById(id) .populate("store_id") .populate("user_id", "name email no_show_count trust_tier"); if (!order) { return res.status(404).json({ success: false, message: "Order not found.", }); } // Verify store ownership if (order.store_id.owner_id.toString() !== userId) { return res.status(403).json({ success: false, message: "You are not authorized to update this order.", }); } // Generate OTP when marking as ready let generatedOtp = null; if (status === "ready") { generatedOtp = generateOtp(); order.otp = generatedOtp; order.otp_expires_at = getOtpExpiry(); order.is_otp_verified = false; order.ready_at = new Date(); order.ready_expires_at = nowPlusMinutes(READY_NO_SHOW_TIMEOUT_MINUTES); } order.order_status = status; await order.save(); // Send OTP via email if (status === "ready" && generatedOtp) { await sendOtpEmail( order.user_id.email, order.user_id.name, generatedOtp, order.order_number, ); } // Send push notification with OTP const notifExtra = status === "ready" && generatedOtp ? { otp: generatedOtp, otpExpiresAt: order.otp_expires_at } : {}; pushOrderNotification(order.user_id._id, order, status, notifExtra); res.json({ success: true, message: `Order status updated to "${status}".`, data: { order: formatOrder(order, { store: order.store_id, customer: order.user_id }), ...(generatedOtp ? { otp: generatedOtp } : {}), }, }); } catch (error) { next(error); }};
The OTP is included in the API response to the store employee so they can reference it if needed. However, the OTP should primarily be communicated to the customer via email/notification.
The validation function uses timing-safe comparison to prevent timing attacks:
backend/src/services/otpService.js
export const validateOtp = (storedOtp, inputOtp, expiresAt) => { if (!storedOtp || !inputOtp || !expiresAt) { return false; } // Check if OTP has expired if (new Date() > new Date(expiresAt)) { return false; } // Use timing-safe comparison const a = Buffer.from(String(storedOtp)); const b = Buffer.from(String(inputOtp)); if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b);};
Why timing-safe comparison?
Regular comparison (===):
if (storedOtp === inputOtp) { ... }
This returns false as soon as it finds a mismatched character. An attacker could measure response times to guess the OTP one digit at a time.Timing-safe comparison:
crypto.timingSafeEqual(a, b)
This always takes the same amount of time regardless of where characters differ, preventing timing attack vectors.
Store employees can manually confirm OTP verification without entering the OTP if:
They verify the customer has the correct OTP (e.g., customer shows it on their phone)
The OTP hasn’t expired
They set manualConfirm: true in the request
This is useful for busy environments where typing the OTP would slow down service.
Example manual confirmation
// Instead of sending the OTP:{ "otp": "123456"}// Send manual confirmation:{ "manualConfirm": true}
Manual confirmation still validates that the OTP exists and hasn’t expired. It just skips the actual OTP matching step, trusting the store employee to have verified it visually.
OTPs expire 15 minutes after generation. If an OTP expires:
The validation function returns false
Store employee cannot verify the order
Store must mark the order as “ready” again to generate a new OTP
New OTP is sent to the customer
Expired OTP flow
// Customer tries to use expired OTPPOST /api/orders/:id/verify-otp{ "otp": "123456"}// Response:{ "success": false, "message": "Invalid or expired OTP."}// Store re-marks as ready:PATCH /api/orders/:id/status{ "status": "ready"}// New OTP generated and sent to customer
If an order remains in “ready” status for too long without OTP verification, the automatic timeout sweep will cancel it as a no-show. Make sure customers collect their orders within the ready timeout window (20 minutes by default).
Customers receive the OTP via email with clear formatting:
backend/src/services/emailService.js
export const sendOtpEmail = async (email, name, otp, orderNumber) => { const content = ` <h2 style="color: #333333; margin-top: 0;">Your Pickup OTP</h2> <p style="color: #555555; line-height: 1.6;"> Hi ${escapeHtml(name)}, your order <strong>${escapeHtml(orderNumber)}</strong> is ready for pickup! </p> <p style="color: #555555; line-height: 1.6;"> Please share the following OTP with the store employee when collecting your order: </p> <div style="text-align: center; margin: 25px 0;"> <div style="display: inline-block; background-color: #FF6B35; color: #ffffff; padding: 15px 40px; border-radius: 8px; font-size: 32px; font-weight: bold; letter-spacing: 8px;"> ${escapeHtml(String(otp))} </div> </div> <p style="color: #888888; font-size: 13px; text-align: center;"> This OTP is valid for 15 minutes. Do not share it with anyone other than the store employee. </p> `; const html = wrapTemplate(content); await sendEmail(email, `Pickup OTP for Order ${orderNumber}`, html);};