Tutorials

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.

StartMessaging Team Updated

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 otpRequestId to 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 as variables.otp along with phoneNumber.
  • SMS delivery (StartMessaging) — The code is sent via SMS with automatic provider fallback if the primary carrier fails.
  • Code checking (StartMessaging) — POST /otp/verify checks 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" and autocomplete="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:

OutcomeAPI ResponseYour Action
Correct code

verified: true

Mark the phone as verified. Proceed with login, registration, or the protected action.

Wrong code, attempts remainingNot verified

Show an error and let them try again.

Wrong code, no attempts leftError (attempt limit reached)

The OTP is invalidated. The user must request a new one.

Expired OTPError (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/verify result 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:

LevelLimitPurpose
Per phone number5 OTPs per hourPrevent harassment and wallet drain for one number
Per IP address10 OTPs per hourPrevent automated abuse from a single source
Per user account20 OTPs per dayPrevent compromised accounts from draining credit
GlobalBased on your expected volumeCatch 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 otpRequestId server-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 otpRequestId in 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" and autocomplete="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/verify checks the user’s input against the code you sent in variables.otp.
  • SMS delivery with fallback — automatic retry via backup SMS providers on delivery failure.
  • Expiry and attempt limits — /otp/verify rejects expired codes and invalidates the OTP after too many wrong attempts.
  • Delivery status — poll GET /messages/:id with the messageId to see queued, sent, delivered or failed.
  • Abuse limits — 3 OTPs per number every 5 minutes and 20 requests per second per account (429 when 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 otpRequestId to 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.

S

StartMessaging Team

StartMessaging Team

Related posts