Best Practices for OTP Input Forms in Android

August 15, 2025
Written by
Alvin Lee
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Best Practices for OTP Input Forms in Android

One-time passwords (OTPs) are a standard method for confirming a user’s contact information and enabling secure login, with or without a password. An OTP can be sent via multiple pathways, including SMS and email.

Using an OTP helps verify a user’s contact information while keeping the login experience simple. For Android apps, implementing OTP is straightforward—as long as you pay attention to a few critical details. In this post, we’ll cover practical tips for creating a smooth, secure OTP flow in your Android app.

Need OTP for other platforms? Check out our companion web HTML and iOS articles.

OTP workflows and use cases

The basic flow for OTP verification looks like this:

  1. The user enters their phone number or email and submits the form to the app.
  2. The app generates a token (for example: 123456) and sends the token to the user’s phone number or email.
  3. The user enters the token into the OTP verification form.
  4. The app verifies the token.

This simple workflow has become nearly ubiquitous. The use cases for OTP verification include:

  1. Contact information verification: Whether it is with email, SMS, voice, or WhatsApp, OTP can be used to authenticate the user’s identity via their contact information.
  2. Two-factor authentication: OTP can also be used as a second authentication factor in combination with another—such as username and password—proving that the person logging in is the person who owns the account.
  3. Account recovery: OTP verification is a common method to provide users a way to restore access in case of lost passwords or other account recovery situations.

OTP has several advantages over other authentication methods. For example:

  • OTP verification is passwordless and does not require the user to remember yet another password.
  • OTP verification does not require a separate app, reducing friction.
  • When implemented well, OTP verification has a streamlined UX, as the OTP token will be auto-filled and submitted for the user. They do not need to exit the current app.

However, even with these advantages, keep in mind that no mechanism is perfectly secure. The security of OTP verification depends on the security of the underlying phone number or email account. Additionally, phishing and recycled phone numbers can present issues.

Let’s look at how we can streamline the process and secure it.

Best Practices for OTP Verification on Android

To streamline and secure the OTP Verification process, follow these best practices:

  1. Decide on the design of the OTP input field
  2. Create a proper input field using either a native Android View or Jetpack Compose.
  3. Use an SMS API to handle autofill and send an appropriately formatted SMS.

Decide on the design of the input field

For the input field of the OTP, you can either use a single input field or multiple input fields (one for each digit). Many design teams opt for the multiple input field solution. This approach makes it clear to users how many digits they must input, and it uses all of the available screen space. It also provides a way to highlight to the user which digit they are currently entering.

However, multiple input fields add complexity regarding handling cursor movement between fields. Additionally, you must also handle autofill or pasting the OTP code into the fields. These problems are solvable, but require some extra effort.

Using a single input field is generally simpler, as it simplifies the implementation of autofilling and or pasting. A single input field does not need any additional logic to handle cursor management.

A hybrid solution is also possible, where a single input field is styled as multiple inputs. Visually, the user will see multiple inputs, but under the hood, your app treats this as a single input. This solution is possible when using Jetpack Compose.

With an awareness of the tradeoffs involved, any of the above approaches can yield an effective OTP input field that provides a smooth user experience.

To create the input field, you can use either a native Android View or Jetpack Compose. We’ll give examples for both.

Create the OTP input field using Jetpack Compose

With Jetpack Compose, you can implement a hybrid solution, where a single input field is styled to appear visually as multiple input fields. For example:

A digital numeric keypad with numbers 1-6 displayed at the top and a standard telephone-style keypad below.

To do this, use a single BasicTextField. For example:

BasicTextField(
    modifier = modifier,
    value = TextFieldValue(otpText, selection = TextRange(otpText.length)),
    onValueChange = {
        if (it.text.length <= otpCount) {
            onOtpTextChange.invoke(it.text, it.text.length == otpCount)
        }
    },
    keyboardOptions = KeyboardOptions(keyboardType = 
        KeyboardType.NumberPassword),
    decorationBox = {
        Row(horizontalArrangement = Arrangement.Center) {
            repeat(otpCount) { index ->
                CharView(
                    index = index,
                    text = otpText
                )
                Spacer(modifier = Modifier.width(8.dp))
            }
        }
    }
)

Note the following key points from this example:

  • The keyboard type is KeyboardType.NumberPassword, providing your user with a numeric keyboard that only displays digits.
  • The length of the input is restricted through the use of onValueChange.
  • Styling is handled through the decorationBox property, which creates a CharView for each character.

You can implement CharView to align with specific stylistic and UX requirements. For example:

private fun CharView(
    index: Int,
    text: String
) {
    val isFocused = text.length == index
    val char = text[index].toString()
    Text(
        modifier = Modifier
            .width(40.dp)
            .border(
                1.dp, when {
                    isFocused -> GreyDark
                    else -> GreyLight
                }, RoundedCornerShape(8.dp)
            )
            .padding(2.dp),
        text = char,

This implementation adds some basic logic to indicate to the user when a digit currently has focus.

Create the OTP input field using a native Android View

Here is an example of a multiple input OTP field using native Android Views.

<LinearLayout
    android:id="@+id/otp_container"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    android:gravity="center"
    android:layout_marginTop="32dp">
    <EditText
        android:id="@+id/otp_digit_1"
        android:layout_width="48dp"
        android:layout_height="48dp"
        android:gravity="center"
        android:inputType="number"
        android:maxLength="1"
        android:importantForAutofill="yes"
        android:textSize="18sp" />
    <!-- Repeat for digits 2-6 -->
</LinearLayout>

Note the following key points from this example:

  • inputType is set to number.
  • importantForAutofill is set to yes.

Remember that while inputType="number" provides a numeric keyboard, OTP codes should always be treated as strings, not numbers. This preserves leading zeros (like in 012345) and trailing zeros (like in 120000) that would be lost if you convert the input to an integer and back to a string. Be especially careful when extracting codes from SMS messages or sending them to your server for verification.

To set the autofill hints for a multiple input field, use generateSmsOtpHintForCharacterPosition(). Here is sample code that uses the library function:

val editTexts = listOf(
      findViewById<EditText>(R.id.otp_digit_1),
      findViewById<EditText>(R.id.otp_digit_2),
      findViewById<EditText>(R.id.otp_digit_3),
      findViewById<EditText>(R.id.otp_digit_4),
      findViewById<EditText>(R.id.otp_digit_5),
      findViewById<EditText>(R.id.otp_digit_6)
  )
for ((index, editText) in editTexts.withIndex()) {
        editText.setAutofillHints(
    SmsOtpToken.generateSmsOtpHintForCharacterPosition(index)
  )
}

Implementing cursor management is also recommended, though not required. This way, if the user manually types in the OTP, the cursor automatically moves from one field to the next. This can be implemented via addTextChangedListener.

for (i in editTexts.indices) {
    val current = editTexts[i]
    val next = if (i < editTexts.size - 1) editTexts[i + 1] else null
    current.addTextChangedListener(object : TextWatcher {
        override fun afterTextChanged(s: Editable?) {
            if (s?.length == 1) next?.requestFocus()
        }
    })
}

Use the SMS Retriever API

Autofill helps you streamline the OTP process, eliminating the need for users to exit the app and manually enter the code. Google Play services provides two different APIs that you can use to accomplish this. The first is the SMS Retriever API.

If we have control over the contents of the SMS message, the SMS Retriever API is the preferred option. To use the SMS Retriever API, follow these instructions install the required Google Play services dependencies then call startSmsRetriever() before the SMS is received.

SmsRetrieverClient client = SmsRetriever.getClient(this /* context */);
Task<Void> task = client.startSmsRetriever();

Once the listening has begun, your app can send a verification SMS. The SMS must meet the following requirements:

  • Be no longer than 140 bytes
  • Contain the OTP code
  • Include an 11-character hash string that identifies your app. Refer to this resource for details on calculating your app's hash string.

Here is an example of the proper format:

Your ExampleApp code is: 123784
FA+9qCX9VSu

You can set up custom OTP formats via Twilio Verify by using a custom template.

Next, use a BroadcastReceiver to handle the SmsRetriever.SMS_RETRIEVED_ACTION intent. This will give the text of the message.

public class MySMSBroadcastReceiver extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        if (SmsRetriever.SMS_RETRIEVED_ACTION.equals(intent.getAction())) {
            Bundle extras = intent.getExtras();
            Status status = (Status) extras.get(SmsRetriever.EXTRA_STATUS);
            switch(status.getStatusCode()) {
                case CommonStatusCodes.SUCCESS:
                    String message =
                        extras.getString(SmsRetriever.EXTRA_SMS_MESSAGE);
                    // Extract one-time code from the message and 
                    // complete verification by sending the code back to
                    // your server.
                    break;
                case CommonStatusCodes.TIMEOUT:
                  // Waiting for SMS timed out (5 minutes)
                 // Handle the error ...
                    break;
            }
        }
    }
}

With the above example implementation, the app can parse the message for the code and then submit it to the server for the user. No manual typing is needed.

The other option is to use the SMS User Consent API. This option prompts the user to give your app access to the contents of a single SMS for the purpose of autofill. To use the SMS User Consent API, you must call startSmsUserConsent() before the SMS is received.

val task = SmsRetriever
    .getClient(context)
    .startSmsUserConsent(senderPhoneNumber /* or null */

Once the listening has begun, the app can send a verification SMS. This SMS must meet the following criteria:

  • The message contains the OTP code, which is 4-10 alphanumeric characters and includes at least one number.
  • The message must be sent within the next 5 minutes.
  • The SMS sender cannot be in the user’s contact list.

In contrast to the SMS Retriever API, this method does not require an app-specific hash code.

Next, use a BroadcastReceiver that has the SEND_PERMISSION permission and responds to SMS_RETRIEVED_ACTION intents. This will give the text of the message.

private val SMS_CONSENT_REQUEST = 2  // Set to an unused request code
private val smsVerificationReceiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        if (SmsRetriever.SMS_RETRIEVED_ACTION == intent.action) {
            val extras = intent.extras
            val smsRetrieverStatus = extras?
                .get(SmsRetriever.EXTRA_STATUS) as Status
            when (smsRetrieverStatus.statusCode) {
                CommonStatusCodes.SUCCESS -> {
                    // Get consent intent
                    val consentIntent = extras.getParcelable<Intent>(SmsRetriever.EXTRA_CONSENT_INTENT)
                    try {
                        // Start activity to show consent dialog to user,
                        // activity must be started in 5 minutes, otherwise
                        // you'll receive another TIMEOUT intent
                        startActivityForResult(consentIntent,
                          SMS_CONSENT_REQUEST)
                    } catch (e: ActivityNotFoundException) {
                        // Handle the exception ...
                    }
                }
                CommonStatusCodes.TIMEOUT -> {
                    // Time out occurred, handle the error.
                }
            }
        }
    }
}
override fun onCreate(savedInstanceState: Bundle?) {
    // ...
    val intentFilter = IntentFilter(SmsRetriever.SMS_RETRIEVED_ACTION)
    registerReceiver(smsVerificationReceiver, SmsRetriever.SEND_PERMISSION, intentFilter)
}

The user will be prompted for permission to read the message contents. From there, the app can parse the message for the code, submitting it to the server on behalf of the user without any manual typing needed.

public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    when (requestCode) {
        // ...
        SMS_CONSENT_REQUEST ->
            // Obtain the phone number from the result
            if (resultCode == Activity.RESULT_OK && data != null) {
                // Get SMS message content
                val message = data.getStringExtra(SmsRetriever.EXTRA_SMS_MESSAGE)
                // Extract one-time code from the message and
                // complete verification
                val oneTimeCode = parseOneTimeCode(message) 
                // send one-time code to the server
            }
    }
}

Testing and Tips

After configuring your input field and SMS delivery, thorough testing is vital. Whether you are using a prebuilt solution and testing with a template from Twilio Verify or testing a custom OTP flow, the following guidance will help you along the way.

Troubleshooting cursor management or autofill issues

If you are using multiple input fields and having trouble with cursor management, remember that you must implement cursor management separately; this functionality is not built-in. Alternatively, consider switching to a single input field or using the hybrid approach with Jetpack Compose.

If you’re having trouble with autofill, first confirm whether you are using the SMS Retriever API or the SMS User Consent API.

  • For the SMS Retriever API, make sure that you have correctly computed your app’s hash string and are including this string in your SMS message.
  • For the SMS User Consent API, ensure that your code meets the length and content criteria outlined above and that the sender is not included in the user’s contact list.
  • For both API cases, ensure that you have started listening for the SMS before it is sent.

Testing and rate limiting

Keep in mind when testing that you are receiving real SMS and will be subject to the protective rate limits of the Verify API. Twilio Verify provides workarounds for testing, including completing or canceling the verification by calling specific endpoints.

Handling errors

Don’t forget to handle error cases. Your form should allow the user to resend and retry the OTP, but with a timeout to deter bad actors. You can also provide alternative channels, such as voice calls, after the user has tried other methods first.

Conclusion

A reliable OTP flow does more than authenticate users—it shapes their experience with your Android app. By combining thoughtful input design with the right autofill API, you can protect your users' accounts while streamlining the verification process. And all of this without forcing users to leave your app.

Following design and implementation best practices ensures your OTP process feels seamless and secure. Getting these details right strengthens both your app’s security and your users’ trust.

Additional Resources