The flow
- Delivery isn’t instant. African carriers sometimes take 30+ seconds to confirm.
- Delivery can fail silently. Your user stares at an empty inbox while you assume success.
- Replay attacks. Without a short TTL + single-use enforcement, old codes work forever.
Reference implementation
Handle the DLR webhook
Whensms.delivered fires, update the UI. When sms.failed fires, offer a fallback immediately — don’t make the user wait for the 5-minute TTL to time out:
Rate limiting the sender
Without per-user rate limits onsendOtp, an abuser can drain your SMS budget by triggering resends:
- Max 5 OTPs per user per hour — prevents a leaked user ID from burning your budget.
- Max 3 verification attempts per code — prevents brute-force.
- Different codes per resend — never reuse the same 6 digits across resends.
WhatsApp / email fallback
Users whose SMS fails benefit from a fallback channel. See the WhatsApp + SMS fallback guide for the pattern.Security checklist
Never log the plaintext code
Never log the plaintext code
Hash it with bcrypt/argon2 before storing. Observation of a log aggregator is enough to compromise every user’s OTP.
Tie codes to a device / IP / session
Tie codes to a device / IP / session
If the verify attempt comes from a different IP than the original request, either require extra verification or block it.
Use constant-time comparison
Use constant-time comparison
bcrypt.compare / hmac.compare_digest — not ===. Prevents timing attacks from leaking the code one character at a time.Include the brand in the message
Include the brand in the message
"Your Shuttlers code is..." trains users to recognize legit codes and distrust phishing attempts.Never send the code and the full context in one message
Never send the code and the full context in one message
Bad:
"Your code is 123456. Use it to approve the ₦50,000 transfer to John Doe at Bank XYZ."Good: "Your code is 123456. Don't share it with anyone." (Context lives in your app UI, not the SMS.)