OTP Login Flow: How to Design and Implement OTP Authentication (2026)

OTP Login Flow: How to Design and Implement OTP Authentication (2026)

OTP login flow illustration: a phone code-entry screen branching to verified, expired and locked states

An OTP login flow looks like three screens: enter a phone number, receive a code, type it in. What decides whether it is secure, and whether users actually finish it, sits behind those screens: how long a code lives, how many guesses you allow, what a resend does to that count, and what your error messages give away.

How does an OTP login flow work?

An OTP login flow confirms that a user controls a phone number by sending a one-time code and checking it before you open a session. It runs in three moments: the user requests a code, your system delivers it, and the user enters it for verification.

One-time codes come from two families. SMS and voice codes are generated on a server and sent to the user’s phone. Authenticator apps generate time-based codes (TOTP) on the device itself, and the TOTP standard, RFC 6238, recommends a default time step of 30 seconds, which is why those codes change every half minute.

If you are still choosing a channel, compare SMS OTP vs authenticator app vs email OTP, or start with how OTPs work.

One boundary worth keeping clear: an OTP authentication flow confirms who the user is. What that user may do once signed in is a separate authorization decision for your roles and permissions layer.

How to implement OTP-based login, step by step

OTP-based login needs three pieces: two endpoints, one record per code request, and a state machine that decides what each request is allowed to do. Build those, and the screens follow.

1. Expose two endpoints

Your app exposes one endpoint to request a code and one to verify it. The names and fields below are a design pattern, not a standard, so adapt them to your API.

POST /auth/otp/request
{ "phone": "+233XXXXXXXXX" }

→ 202 Accepted
{ "request_id": "otp_8f2c", "resend_after_seconds": 60 }
POST /auth/otp/verify
{ "request_id": "otp_8f2c", "code": "482913" }

→ 200 OK      { "session_token": "..." }
→ 401 Unauthorized { "error": "invalid_or_expired" }
→ 429 Too Many Requests { "error": "try_again_later", "retry_after_seconds": 300 }

The request endpoint returns the same response whether or not the number belongs to an account. The account-existence section below explains why that matters.

2. Store one record per code request

For each request, keep a record with these fields:

  • Phone number or user ID the code was sent to
  • Code hash, or a provider reference if a provider generates the code for you
  • Expires at, a timestamp you check on every verify
  • Failed attempts, counted per phone number or account, not per code
  • Resend count and last sent at, which drive the cooldown
  • Status: pending, verified, expired or locked

Store a hash of the code rather than the code itself, so a database leak does not expose live codes. Keep the failed-attempt count on the account or number, because a counter that lives on the code record starts again at zero every time a new code is issued.

3. Run the state machine

NIST SP 800-63B, the digital identity guidelines from the US National Institute of Standards and Technology, sets the rules your states enforce. An SMS or voice OTP login is invalid unless it is completed within 10 minutes, and each code may be accepted only once.

NIST also requires codes of at least six random digits, a limit on consecutive failed attempts for codes that short, and states that generating a new code must not reset the failed-attempt count. It caps consecutive failed attempts at no more than 100, calls that an upper bound you are free to lower, and suggests growing waits between failures, for example 30 seconds up to an hour.

Both the 10-minute and 100-attempt figures are ceilings, not targets. Set your own expiry and attempt limit well inside them; our guide on choosing OTP expiry and rate limits walks through the trade-offs.

StateTriggerNext stateWhat the user sees
PendingCode sentPending“Enter the 6-digit code sent to the number ending 45”
PendingResend tapped after cooldownPending, new code, same failed countA fresh code arrives and the cooldown timer restarts
PendingWrong code, under your limitPending, failed count +1“That code didn’t work. Check your latest message and try again.”
PendingCorrect code within expiryVerified, code consumedSigned in
PendingExpiry time passesExpired“This code has expired. Request a new one.”
PendingFailed count reaches your limitLocked“Too many attempts. Try again in a few minutes.”
LockedWait period endsRequests allowed againResend becomes available
OTP login flow state machine: pending moves to verified, expired or locked, while resends and wrong codes keep the failed-attempt count

Two rules belong in code, not in a comment. A resend issues a fresh code but carries the failed-attempt count forward. A successful verify marks the code consumed in the same transaction that creates the session, so a replayed code fails.

4. Or let a provider generate and check the code

If you would rather not generate, send and expire codes yourself, a verification API handles that part. Arkesel Phone Number Verification uses two POST calls, documented in Arkesel’s OpenAPI specification: /api/otp/generate and /api/otp/verify on sms.arkesel.com, authenticated with an api-key header that must carry your main SMS API key.

The generate call takes the phone number, a sender ID, the delivery medium (SMS or voice), the code type (numeric or alphanumeric) and a message template. The template must contain an %otp_code% placeholder, or the API rejects the request. You also set code length (6 to 15 digits) and expiry (1 to 10 minutes) on each request, so a payment code and a signup code can follow different rules.

A generate request with a JSON body looks like this, with your own number, sender ID and values:

POST https://sms.arkesel.com/api/otp/generate
api-key: YOUR_MAIN_SMS_API_KEY
Content-Type: application/json

{
  "number": "233XXXXXXXXX",
  "sender_id": "YourBrand",
  "medium": "sms",
  "type": "numeric",
  "length": 6,
  "expiry": 5,
  "message": "Your login code is %otp_code%"
}

The verify call takes only the code and the phone number, and returns 1100 for success, 1104 for an invalid code and 1105 for an expired one. Map those three results onto the states in the table above.

POST https://sms.arkesel.com/api/otp/verify
api-key: YOUR_MAIN_SMS_API_KEY
Content-Type: application/json

{ "number": "233XXXXXXXXX", "code": "482913" }

For exact request syntax, see the Arkesel API documentation. For a full integration walkthrough, follow our OTP API integration guide.

A provider takes code generation, delivery and expiry off your hands. Your app still owns three things:

  • Its own failed-attempt counter per number or account, checked before you call verify, so your limit holds whatever the provider does.
  • The resend cooldown on your request endpoint.
  • The session. Mark the request verified once, issue the session, and refuse any further verify calls for that request.

Arkesel also applies rate limiting per phone number on its side, to curb floods of code requests (often called OTP bombing) and brute-force guessing. Treat that as a second layer on top of your own counter, not a replacement for it.

Ready to try the two-call flow? See how Arkesel Phone Number Verification works and what you can configure per request.

Three OTP login flow examples

These OTP examples show the screens in order, the timing choices and the failure branches. The timings are design choices you make inside the NIST ceilings, not standards.

Example 1: Signup phone verification

  1. Phone number screen. The user enters a number and taps “Send code”. For quick onboarding, send the code as soon as the number is submitted.
  2. Code screen. Six input boxes, the masked number, a “Change number” link and a Resend button that stays disabled until the cooldown ends. Put the cursor straight into the first box.
  3. Verified. Continue to the rest of onboarding.

Timing: pick an expiry you can defend inside the 10-minute ceiling and a resend cooldown long enough to let a delayed SMS arrive.

Failure branches:

  • Wrong number entered: “Change number” returns to screen 1. The failed count stays attached to the number already tried.
  • No SMS: after the cooldown, offer Resend, then “Call me with the code” by voice.
  • Too many wrong codes: show the locked message with the wait time, not a generic error.

On the web, the WebOTP API can fill in the code with the user’s consent when the SMS’s last line carries your domain after an @ and the code after a #, for example @example.com #482913. MDN marks it experimental with limited browser availability, so keep manual entry as the default and treat autofill as a bonus.

Example 2: Card-payment step-up

  1. Confirm payment. The user reviews the amount and taps Pay.
  2. Code screen. The screen and the SMS both name the amount and the merchant, so the user can spot a payment they did not start.
  3. Payment approved or declined.

Design rule: bind the code to that one transaction. A code issued for one payment never approves another, and a change to the amount cancels the code and issues a new one.

Failure branches:

  • Wrong code: the failed count rises; the payment stays pending.
  • Code expired: the payment stays pending until the user requests a new code.
  • Limit reached: decline the payment and notify the account holder through a channel they already trust.

For the wider controls around payment codes, see our guide to OTP for fintech transactions.

Example 3: Password reset

  1. Enter phone number or email.
  2. Neutral confirmation. “If an account uses these details, you’ll receive a code shortly.”
  3. Code entry.
  4. Set a new password, then sign the user out of other sessions.

OWASP’s Forgot Password Cheat Sheet says a reset request should return the same message for existing and non-existent accounts, take a consistent amount of time either way, and be rate-limited per account so an attacker cannot flood a user’s SMS or inbox with codes.

Recovery codes carry their own ceiling: NIST limits issued recovery codes to 10 minutes when sent by text message or voice and 24 hours when sent to an email address.

Failure branches:

  • Unknown number or email: show screen 2 anyway and send nothing.
  • Code expired: return to screen 1 with the details pre-filled.
  • Limit reached: lock resets for that account and show the wait time.

Should your OTP flow reveal whether an account exists? The security vs UX tradeoff

By default, no. OWASP’s Authentication Cheat Sheet says login, password reset and password recovery must return a generic error whether the account does not exist, the password is wrong or the account is locked, and suggests the same approach for registration.

A message such as “No account found for this number” lets anyone run a list of phone numbers through your login screen and learn which ones belong to your users.

OWASP is direct about the cost. A generic message is a user-experience problem, and the decision depends on how critical your application and its data are. Where a feature cannot use a generic message without hurting usability, OWASP points to brute-force protection and CAPTCHA instead.

Wording is only half the job. If your request endpoint answers faster for unknown numbers because it skips sending an SMS, the response time gives the account away. Queue the send and return the same response on the same schedule either way.

ScreenCopy that reveals the accountEnumeration-safe copy
Login code request“No account uses this number. Sign up?”“If this number is registered, we’ve sent a code.”
Wrong code“Wrong code for Ama’s account.”“That code didn’t work. Try again or request a new one.”
Password reset“We couldn’t find that account.”“If an account uses these details, you’ll receive a code shortly.”
Signup“This number is already registered.”“We’ve sent a code to this number. Enter it to continue.”
Comparison of OTP authentication error messages that reveal an account versus enumeration-safe versions for login, wrong code, password reset and signup

With the enumeration-safe signup copy, the existing-account case is handled after the code is verified, for example by offering to sign the user in.

How to choose: a banking or payments app uses enumeration-safe copy on every screen. A lower-risk consumer app can accept clearer signup copy and protect it with CAPTCHA and per-number rate limits.

Email OTP workflow: where it fits and where it doesn’t

Email codes fit two jobs: confirming that a user owns an email address, and account recovery. They are a poor choice for the login second factor. NIST says email must not be used as an out-of-band authentication channel, because a mailbox is often protected only by a password and messages can be intercepted in transit or at mail servers, while it exempts codes that validate an email address or serve as recovery codes.

Is email OTP secure enough for login?

Not as the step that proves a login. Use SMS or voice codes or an authenticator app for that step, and keep email for the jobs NIST allows.

A sound email OTP workflow

  1. Address verification at signup. Send a code or link, mark the address verified when it is used, and do not grant extra access on the strength of that alone.
  2. Account recovery. Send a recovery code, apply the 24-hour email ceiling from Example 3 or a shorter expiry you choose, then require a new password.
  3. Everything else stays the same. Single use, a failed-attempt counter that survives a resend, enumeration-safe copy and per-account rate limits apply to email codes exactly as they do to SMS.

How email differs operationally from SMS

  • Lifetime. Recovery codes by email may live longer than codes sent by text message or voice, which suits a user who checks mail later.
  • Protection. The code is only as safe as the mailbox password, so a reused password weakens every email code you send.
  • Delivery. Test whether your code emails reach the inbox or the spam folder with the mail services your users rely on, and keep the subject line plain enough that users recognise it.

What should happen when the OTP doesn’t arrive?

Give the user a way forward on the same screen: a resend after the cooldown, then a voice call, then a backup method for accounts that have one.

  • Resend. Issue a fresh code and keep the failed-attempt count, as in the state machine above.
  • Voice call. Read the code aloud to the same number. With Arkesel Phone Number Verification, voice is a channel you request, not an automatic retry: a failed SMS is not resent by voice for you, and voice OTP covers Ghanaian numbers only. Nigerian, South African and Tanzanian numbers receive the code by SMS only, so for users there, resend and backup codes are the recovery path. A Ghanaian user who misses an SMS code can also dial a USSD code to view it. Confirm in the Arkesel API documentation how to set the medium on each request, and see how SMS, USSD and voice compare for OTP delivery before you design around any one channel.
  • Backup codes. For accounts where a lost phone would mean a lockout, issue backup codes at setup and accept one when no code arrives.
  • When silent authentication isn’t supported. Some apps first try silent authentication, a check of the number that runs without the user typing a code. If the device or network doesn’t support it, fall back to the OTP code screen instead of showing an error.
Fallbacks when an OTP code doesn't arrive: resend keeping the failed count, then a voice call, then backup codes

Check risk before you send. NIST lists phone-network delivery of codes by SMS or voice as its one restricted authenticator, which means permitted after you assess the risk rather than banned. It advises checking signals such as a SIM change, device swap or number porting before sending a code that way, so use those signals wherever your stack gives you access to them.

OTP authentication security checklist

SettingRuleSource
Code lengthAt least six random digitsNIST SP 800-63B
LifetimeInvalid after 10 minutes at most; shorter is fineNIST SP 800-63B
ReuseEach code accepted onceNIST SP 800-63B
Failed attemptsLimit required; 100 is the ceiling, lower is allowedNIST SP 800-63B
ResendNew code keeps the failed-attempt countNIST SP 800-63B
WaitsGrowing delay between failuresNIST SP 800-63B
Error copyGeneric message, consistent response timeOWASP
Rate limitsPer account or number, on request and verifyOWASP
Channel riskCheck SIM change and porting before SMS or voiceNIST SP 800-63B

To pick exact values, use the expiry and rate-limit guide linked above. For controls beyond the login screen, read our OTP security best practices, and protect your request endpoint against SMS pumping fraud.

How do you test an OTP login flow?

Test the branches of your OTP login flow that users hit on a bad day, not only the happy path.

  • Delivery speed. Measure the time from request to code arrival for SMS and for voice, on the mobile networks your users are on. A slow code invites repeated resend taps, so know your real delivery times.
  • Every state change. Wrong code, expired code, resend during and after the cooldown, lockout and unlock, and a replayed code after a successful login.
  • The failed count across resends. Enter wrong codes, request a new one and confirm the count did not return to zero.
  • Enumeration. Compare the response body and response time for a registered and an unregistered number.
  • Error copy. Read every message and confirm it tells the user what to do next.
  • Drop-off. Track how many users reach the code screen and how many complete it. A gap there points to delivery, copy or cooldown problems.

When an integration misbehaves, work through our list of common OTP API errors before changing your flow.

Build your OTP login flow with Arkesel

Good OTP authentication comes down to a few rules applied consistently: short-lived single-use codes, a failed-attempt count that survives every resend, and copy that gives nothing away. Keep that state machine in your app, and let two API calls handle the code for users in Ghana, Nigeria, South Africa or Tanzania.

Create your Arkesel account to get your API key and send a test code to your own phone, and check current pricing before you go live.

Scroll to Top