Build a Complete OTP Verification Flow
Architecture guide for building a production-ready OTP verification flow covering generation, delivery, verification, retry logic, expiry, and security best practices.
OTP (one-time password) verification is the backbone of phone-based authentication in India. While sending a single OTP is straightforward, building a production-ready verification flow involves many more decisions: retry logic, expiry handling, rate limiting, security hardening, and smooth user experience.
This guide walks through the complete architecture of an OTP verification flow. It is language-agnostic — the patterns apply whether you are building with Node.js, Python, PHP, or any other backend. We will use the StartMessaging API as the OTP provider, which handles code generation, hashing, delivery, and verification so you can focus on your application logic.
Flow Overview
A complete OTP verification flow consists of four stages:
- Initiation — The user requests verification (e.g. enters their phone number on a registration form).
- Generation and Delivery — Your backend generates a code and calls the OTP API, which sends it via SMS and returns an
otpRequestId. - User Input — The user receives the SMS and enters the code in your application.
- Verification — Your backend sends the code and
otpRequestIdto the OTP API, which checks the code and enforces expiry and attempt limits.
Here is how the data flows between all parties:
User Your Frontend Your Backend StartMessaging SMS Provider
| | | | |
|-- Enter phone ->| | | |
| |-- POST /send-otp -->| | |
| | |-- POST /otp/send -->| |
| | | |-- Deliver SMS ----->|
| | |<--{ otpRequestId }-| |
| |<-- { sent: true } -| | |
|<-- Show OTP input --| | | |-- SMS -->User
| | | | |
|-- Enter code ->| | | |
| |-- POST /verify --->| | |
| | |-- POST /otp/verify->| |
| | |<-- { verified } ---| |
| |<-- { verified } ---| | |
|<-- Success/Fail --| | | |
Step 1: Initiation
When the user submits their phone number, your frontend sends it to your backend. Before forwarding to the OTP API, your backend should:
- Validate the phone number format. Ensure it is a
valid E.164 number (e.g.
+919876543210). Reject invalid formats immediately without calling the API. - Check rate limits. Prevent a single phone number or IP address from requesting too many OTPs in a short period.
- Check for existing pending requests. If the user already has a pending OTP, decide whether to reuse it (by returning the existing request ID) or to invalidate it and create a new one.
Step 2: Generation and Delivery
Your backend generates the code and calls the POST /otp/send endpoint on the
StartMessaging API. The split of responsibilities:
- Code generation (your backend) — Generate a cryptographically random 4-6 digit code, for example
crypto.randomInt(100000, 1000000)in Node.js, and pass it asvariables.otpalong withphoneNumber. - SMS delivery (StartMessaging) — The code is sent via SMS with automatic provider fallback if the primary carrier fails.
- Code checking (StartMessaging) —
POST /otp/verifychecks the user’s input against the code you sent, so you do not need to store the code yourself. - Expiry and attempt limits (StartMessaging) — When you verify through
POST /otp/verify, StartMessaging enforces the code’s expiry and the maximum number of verification attempts.
Your backend receives a 201 response with data.otpRequestId and
data.messageId (the top-level requestId is only a trace ID for the
HTTP call). Store the otpRequestId and the send time in your session or
database, associated with the user or phone number. Use the messageId
with GET /messages/:id if you want to track delivery.
Always include an idempotency key in your send request. This prevents duplicate SMS delivery if a network retry causes the request to be processed twice.
Step 3: User Input
Your frontend shows an OTP input screen. The user checks their SMS and types in the 6-digit code. Key UX considerations at this stage:
- Show a countdown timer indicating how much time remains before the code expires.
- Provide a “Resend” button that is initially disabled and becomes active after a cooldown period (typically 30 to 60 seconds).
- Use an input field with
inputMode="numeric"andautocomplete="one-time-code"to help mobile browsers auto-fill the OTP from SMS. - Show clear feedback when the user has entered all 6 digits. Some applications auto-submit at this point.
Step 4: Verification
When the user submits the code, your frontend sends it to your backend.
Your backend looks up the stored otpRequestId and calls
POST /otp/verify with { requestId: otpRequestId, otpCode }.
Possible outcomes:
| Outcome | API Response | Your Action |
|---|---|---|
| Correct code |
| Mark the phone as verified. Proceed with login, registration, or the protected action. |
| Wrong code, attempts remaining | Not verified | Show an error and let them try again. |
| Wrong code, no attempts left | Error (attempt limit reached) | The OTP is invalidated. The user must request a new one. |
| Expired OTP | Error (OTP expired) | Prompt the user to request a new OTP. |
Retry and Resend Logic
There are two distinct retry concepts in an OTP flow, and it is important not to confuse them:
Verification retries (wrong code)
When the user enters the wrong code, they should be allowed to try
again up to the attempt limit. StartMessaging enforces this limit on
/otp/verify; once it is exhausted, the OTP is invalidated and the user
must request a new one. Your frontend should make it clear when a new
code is needed.
Resend (new OTP)
If the user does not receive the SMS, they may request a new OTP. Implement the following safeguards:
- Cooldown period: Enforce a minimum wait time (30-60 seconds) between resend requests. This prevents abuse and gives the SMS time to arrive.
- Maximum resends: Cap the number of OTPs that can be sent to a single phone number in a given time window (e.g. 5 OTPs per phone number per hour).
- Invalidate previous OTP: When a new OTP is sent, the previous one should be considered invalid. Only the most recent request ID should be accepted for verification.
- New idempotency key: Each resend must use a new idempotency key since it is intentionally a new OTP request.
Expiry Handling
OTP codes must expire after a fixed duration. When you verify through
/otp/verify, StartMessaging enforces expiry for you. The send
response does not include an expiry timestamp.
Handle expiry on both sides:
- Frontend: If you show a timer, base it on the send time your backend records and treat it as a guide. Always offer a “Request new OTP” button once the resend cooldown has passed.
- Backend: Treat the
/otp/verifyresult as the source of truth. The API rejects expired codes regardless of what your frontend shows.
Rate Limiting
Rate limiting is critical for preventing both financial abuse (someone draining your wallet) and user harassment (someone spamming a phone number). Implement rate limits at multiple levels:
| Level | Limit | Purpose |
|---|---|---|
| Per phone number | 5 OTPs per hour | Prevent harassment and wallet drain for one number |
| Per IP address | 10 OTPs per hour | Prevent automated abuse from a single source |
| Per user account | 20 OTPs per day | Prevent compromised accounts from draining credit |
| Global | Based on your expected volume | Catch anomalies and prevent runaway spending |
Implement these limits in your backend before calling the
StartMessaging API. Use Redis or your database for tracking. StartMessaging
also enforces 3 OTPs per number every 5 minutes and 20 requests per
second per account, returning 429 Too Many Requests when exceeded.
Security Considerations
- Never expose OTP codes in your API responses. Your backend can keep the
otpRequestIdserver-side and should never send the code itself to the frontend. StartMessaging never echoes the code back in API responses — it reaches the user only via SMS. - Never log OTP codes. Your backend generates the code, passes it in
variables.otp, and can discard it right away. Verification goes through/otp/verify, so there is no reason to store or log the plaintext code. - Use HTTPS everywhere. All communication between your frontend, backend, and the StartMessaging API must be over TLS.
- Rely on attempt limits. StartMessaging limits verification attempts per OTP on
/otp/verify, so an attacker cannot brute-force a 6-digit code. Add your own lockouts for users who fail repeatedly across many OTPs. - Prevent enumeration attacks. Do not return different error messages for “phone number not found” versus “OTP expired.” Use generic error messages on your frontend.
- Bind OTP to purpose. An OTP generated for login should not be valid for password reset. If your application has multiple OTP use cases, use separate request flows for each.
- Implement session binding. Store the
otpRequestIdin a server-side session or signed token, not in a client-side cookie that can be tampered with.
OTP Request State Machine
An OTP request transitions through a clear set of states. Thinking of it as a state machine helps you handle every edge case:
CREATED ──── SMS sent successfully ────> PENDING
| |
|── SMS delivery failed ──> FAILED |── User enters correct code ──> VERIFIED
|
|── User enters wrong code (attempts > 0) ──> PENDING (decremented)
|
|── User enters wrong code (attempts = 0) ──> EXHAUSTED
|
|── Time expires ──> EXPIRED
Terminal states are VERIFIED, EXHAUSTED, EXPIRED, and FAILED. Once in a terminal state, the request cannot transition further. The user must start a new request.
Frontend UX Patterns
Good UX is essential for OTP flows. A frustrating verification experience drives users away. Here are the key patterns:
Phone number input
- Pre-fill the country code for Indian numbers (
+91). - Validate the number format in real time. Show an error before the user submits if the format is invalid.
- Disable the submit button while a request is in flight to prevent double sends.
OTP code input
- Use a 6-box input where each box accepts one digit. Auto-advance focus to the next box as the user types.
- Set
inputMode="numeric"andautocomplete="one-time-code"for native OTP auto-fill on Android and iOS. - Support paste so users can paste the full code at once.
- Show a countdown timer for expiry. Format it as “4:32 remaining” not “272 seconds.”
Error states
- Wrong code: “Incorrect code. Please try again.”
- Expired: “This code has expired. Please request a new one.” with a resend button.
- Max attempts: “Too many incorrect attempts. Please request a new code.” with a resend button.
- Rate limited: “Too many requests. Please wait a few minutes before trying again.”
Implementing with StartMessaging
The StartMessaging OTP API handles the delivery side of the complexity described in this guide. Here is what the API manages for you:
- Code verification —
/otp/verifychecks the user’s input against the code you sent invariables.otp. - SMS delivery with fallback — automatic retry via backup SMS providers on delivery failure.
- Expiry and attempt limits —
/otp/verifyrejects expired codes and invalidates the OTP after too many wrong attempts. - Delivery status — poll
GET /messages/:idwith themessageIdto see queued, sent, delivered or failed. - Abuse limits — 3 OTPs per number every 5 minutes and 20 requests per second per account (
429when exceeded). - ** Idempotency ** — duplicate send requests return the original response.
- ** DLT compliance ** — all Indian telecom regulatory requirements handled.
What you need to implement on your side:
- Secure code generation (4-6 digits from a cryptographic random source).
- Phone number validation before calling the API.
- Rate limiting at the application level (per phone, per IP, per user).
- Session management to bind
otpRequestIdto the user securely. - Frontend UX (input, countdown, error handling).
- Post-verification action (mark phone as verified, issue session token, etc.).
For language-specific implementation details, see our tutorials for Node.js, Python, and PHP/Laravel.
FAQ
StartMessaging provides a complete OTP API at Rs 0.25 per OTP with no monthly commitments. See use cases or get started with the API documentation.
StartMessaging Team
StartMessaging Team