Best practices for managing retry logic with SMS 2FA

July 27, 2021
Written by
Reviewed by

Humans are impatient creatures, so while SMS verification or two-factor authentication (2FA) codes may come through quickly in most parts of the world, we always recommend building retry buffers into verification workflows. This helps prevent:

  • Accidentally spamming a user with repeated text messages
  • Hitting API rate limits
  • Toll fraud or unnecessary spend

While the best practices in this post are written with the Twilio Verify API in mind, many apply regardless of your 2FA provider. Combined with other best practices like building an allow list of country codes to verify, these steps can help make sure your user verification workflow is as seamless as possible.

Launch a demo application with SMS retry best practices

This project is also available to Quick Deploy on the Twilio Code Exchange -- no code required!

I built an application that shows off the best practices outlined in this post. The application is quick to launch since it's built with Twilio Functions, Twilio's serverless environment. You can launch your own by following these instructions.

Prerequisites include:

  • Node.js
  • A free Twilio account (sign up for free using this link and receive $10 in credit when you upgrade your account)
  • A Verify Service. Create one in the Twilio Console.

Clone or download the sample project on my GitHub:

git clone https://github.com/robinske/verify-retry.git && cd verify-retry

Install the dependencies with npm install. Then rename the .env.example file to .env (for security, the .env file is part of the .gitignore and won't be committed to source) and fill in the variables with your Account SID and Auth Token, which can be found in the Twilio Console. Fill in the Verify Service SID that you created before:

# find in the twilio console: https://twilio.com/console
ACCOUNT_SID=
AUTH_TOKEN=

# create one in the twilio console: https://twilio.com/console/verify/services
VERIFY_SERVICE_SID=

Launch the project with npm start and navigate to http://localhost:3000/index.html to test it out.

Best practices for retrying SMS 2FA codes

Implement timeouts on the resend button.

Add a buffer between retries to prevent bad behavior or accidentally sending multiple codes. We recommend starting with 30 seconds.

 

grayed out resend code option that says "resend code in 22 seconds"

In the demo, we count down from the max timeout and keep the button disabled until the timeout has expired. For a less animated alternative, you could:

  • display the countdown with 5 seconds left
  • gray out the resend button until the timeout has expired with copy that states the total buffer (without counting down)
  • only display the retry link once the timeout has expired

Track retry attempts

The Verify API includes a list of verification attempts in the response, which you can use to increase the retry buffer with each additional attempt. You can also use the number of attempts to enforce your own rate limits in addition to the Verify API rate limits (5 verification starts in a 10 minute period).

An example of increasing timeout with more attempts can be seen in this function. The default timeout here is the maximum (10 minutes) which can help prevent your application from hitting API rate limits.

function getRetryTimeout(attemptNumber) {
 const retryTimeouts = {
   1: 30,
   2: 40,
   3: 60,
   4: 90,
   5: 120,
 };

 return retryTimeouts[attemptNumber] || 600;
}

Best practices for fallback channels

Offer alternate channels like Voice on the 3rd verification attempt

Voice calling gets priority in telephony networks and can help ensure your customers get a verification code. However, the voice channel can be abused for toll fraud so unless you detect a landline or have a business case for calls, we recommend waiting to expose this channel until the 3rd or 4th attempt to send an SMS has been made or disabling it all together.

Display a "Call me instead" option in your user experience once multiple SMS attempts have been made:

 

one time passcode input field with a grayed out resend code message and a clickable link that says "having trouble receiving SMS, call me instead"

Detect landlines

In addition to using Twilio's Lookup API for detecting invalid phone numbers, you can use the API to detect landline phone numbers and use the call channel for these numbers instead of defaulting to SMS.

If you enter a landline in the example project it will automatically call instead of text the verification code.

 

one time passcode input field with a message that says "landline detected. sent call verification"

Disable unused channels in the Twilio Console

If you want to disable certain channels altogether, you can do so in the Verify section of the Twilio Console.

 

twilio verify console showing disabled call and email channel toggles

Implement reCAPTCHA for voice calls

Implement reCAPTCHA to help detect and prevent bots in your verification flow. Learn more about how to implement this feature in Google's developer documentation.

Adding additional rate limits

The Twilio Verify API supports programmable rate limits that you can apply to particular segments based on the request like an IP address, a geolocation, or a country code.

General user verification best practices

Retry logic is one component of building a seamless user verification workflow. A few other best practices include:

1. Use Twilio's Lookup API to detect invalid numbers and line type before sending a verification

In addition to using Carrier Lookup for identifying landlines, Lookup can be used to identify invalid numbers before you attempt to send a verification code.

2. Build an allow or block list of countries

Using an allow list of countries at sign-up is a great way to ensure you're meeting compliance requirements, reducing fraud, or otherwise controlling your onboarding pipeline.

3. Display complete phone numbers for initial user verification

For phone verification use cases (as opposed to ongoing login or two-factor authentication), display the complete phone number in the interface so the user can detect any typos.

 

one time passcode input field with message that shows complete phone number with option to edit.

4. Mask phone numbers for ongoing login or two-factor authentication

Once the phone number has been verified the first time, subsequent uses should mask the phone number in order to prevent leaking PII. Unlike the above, there is no option to edit a phone number for ongoing authentication. We recommend exposing 3 or 4 numbers and masking the rest like +1 (5**) ***-**67 or ********567.

one time passcode input field with message that shows obfuscated phone number and no option to edit.

Optional: deploy the project with Twilio Functions

To deploy this project with Twilio Functions you'll need:

Once you install those dependencies, you can deploy this project by running the following command from the verify-retry folder:

twilo serverless:deploy

Next steps with user verification

As the usable privacy researcher Miranda Wei has said, we should think about building usable security as a form of "customer service, where the users are trying to achieve security and it's something that is constantly changing. It's not something that you can just set once and forget about." These best practices are a good start, but we recommend monitoring your support costs and user satisfaction to make sure you're providing the best solution as both your product and authentication technology evolves.

For more resources, you might want to check out:

I can't wait to see what you build and secure!