← Blog

Phone Authentication in Ruby

Using SMS to authenticate a user has the following benefits:

  • Everybody has a phone.
  • Users don’t have to remember passwords.
  • Protect against robots & duplicate accounts.
  • Familiarity.

You should not use SMS as a sole authentication method for systems that require high security.

What I am going to cover in this article is how to make an API that generates “phone tokens” that can be used to sign-in / sign-up. This way you don’t need to create a user record before a phone is verified.

User stories

Below are 2 user stories we are going to cover.

As a returning user.

I want to sign-in.

I go to the login screen and enter my phone.

I receive an SMS with the verification code, enter the code.

I am logged in.

As a new user.

I want to sign-up.

I go to the login screen and enter my phone.

I receive an SMS with the verification code, enter the code.

I enter my name and email and hit submit.

I am registered and logged in.

To make the 2nd story (sign up) work, upon phone verification we want to return a secure phone token that, together with name and email, can be exchanged to create a new account.

Phone token is going to be an encoded phone string signed by our secret key as proof that it originated from our system.

Pseudo-code API (Controller Code)

An important bit is that both sign-in and sign-up flows start with entering a phone number and getting a phone token. Your app can then check if a user with such phone already exists, if it does, then simply login, otherwise proceed to sign up.

# ask user for a phone and send an SMS code
POST /send-phone-verification { phone: '+19178456780' }
PhoneVerification.send_verification(phone: params[:phone])
=> {}

# verify the phone entering the code from SMS,
# get encoded phoneToken in return
POST /verify-phone { phone: '+19178456780', code: '123456' }
phone_token = PhoneVerification.code_to_phone_token(
  phone: params[:phone],
  code: params[:code]
)
=> { phoneToken: phone_token }

# exchange the phoneToken to a user information
POST /sign-in { phoneToken: 'xxxxxxxxxxxxx' }
trusted_phone = PhoneVerification.phone_token_to_phone(params[:phone_token])
user = User.find_by_phone(trusted_phone)
=> { user: user }

# OR

# use the phone token to create a new user with verified phone
POST /sign-up { phoneToken: 'xxxxxxxxxxxxx', name: 'John Doe', email: 'john@example.com' }
trusted_phone = PhoneVerification.phone_token_to_phone(params[:phone_token])
user = User.find_or_create_by!(phone: trusted_phone) do |u|
  u.name = params[:name]
  u.email = params[:email]
end
=> { user: user }

Implementation

In order to implement this we are going to need the following:

Without further ado, let’s jump into code

class PhoneVerification
  class << self
    # time that user has before a code expires
    EXPIRATION = 5.minutes

    # an SMS can't be sent more frequently than that
    TIME_BEFORE_RESEND = 30.seconds

    # how many times can a user enter an invalid code
    MAX_ATTEMPTS = 5

    # if a user has entered an invalid code
    # this is how long he has to wait before sending a new one
    TIME_BEFORE_RETRY = 10.minutes

    # once a phone is verified, a phone token
    # needs to be used within that time frame
    PHONE_TOKEN_EXPIRATION = 1.hour

    # how many digits there are in the verification code
    CODE_LENGTH = 6

    # Sends a verification code to a given phone number
    def send_verification(phone:)
      raise StandardError, "Phone can't be blank" if phone.blank?
      raise StandardError, "Please enter a valid phone" unless PhoneValidator.valid?(phone)

      # check if a code was already sent to this phone number
      existing_code = redis.get(phone_key(phone))
      if existing_code

        # if a code was already sent, we need to check
        # that time has passed before re-sending it
        # we don't want to allow users to send too many SMS
        # because they cost money and could be abused
        too_early = redis.get(resend_key(phone)).present?

        raise StandardError, "Can't resend a code this soon" if too_early

        # verify that the maximum number of attempts was not yet reached
        attempts = redis.get(attempts_key(phone)).to_i
        raise StandardError, "You reached the maximum number of attempts, please wait" if attempts > MAX_ATTEMPTS

        # if time has passed, we re-send the same code
        code = existing_code
      else
        # generate N digit code
        code = SecureRandom.random_number(10 ** CODE_LENGTH).to_s.rjust(CODE_LENGTH, "0")

        # save this code in redis under the given phone number
        redis.set(phone_key(phone), code, ex: EXPIRATION, nx: true)
        # set attempts to 0
        redis.set(attempts_key(phone), "0", ex: EXPIRATION, nx: true)
      end

      # reset a timer for being able to send a code for a given phone number
      redis.set(resend_key(phone), "true", ex: TIME_BEFORE_RESEND, nx: true)

      content = "Verification code: #{code}"
      # actually send the SMS using twilio or some other service
      Messenger.call(phone: phone, message: content)
    end

    # Verifies that a code is valid
    # and returns an encoded phone token
    # that proves the phone was verified
    def code_to_phone_token(phone:, code:)
      raise StandardError, "Phone can't be blank" if phone.blank?
      raise StandardError, "Please enter a valid phone" unless PhoneValidator.valid?(phone)
      raise StandardError, "Code can't be blank" if code.blank?

      real_code = redis.get(phone_key(phone))
      if real_code.nil?
        raise StandardError, "The code has expired"
      end
      attempts = redis.get(attempts_key(phone)).to_i
      attempts += 1
      # if number of attempts has exceeded the threshold
      # then don't let a code to be sent until some time passes
      if attempts > MAX_ATTEMPTS
        # prolong code and attempts expiration,
        # user won't be able to send new code until this time passes
        redis.set(phone_key(phone), real_code, ex: TIME_BEFORE_RETRY)
        redis.set(attempts_key(phone), attempts.to_s, ex: TIME_BEFORE_RETRY)
        raise StandardError, "You reached the maximum number of attempts, please wait"
      end
      is_valid = ActiveSupport::SecurityUtils.secure_compare(code, real_code)
      unless is_valid
        # prolong phone key expiration
        redis.set(phone_key(phone), real_code, ex: EXPIRATION)
        # save updated attempts count
        redis.set(attempts_key(phone), attempts.to_s, ex: EXPIRATION)
        raise StandardError, "The code is invalid"
      end
      # contents of the phone token
      # using this token we can always lookup a user
      # by phone and make sure the token hasn't expired
      payload = {
        phone: PhoneValidator.clean(phone),
        iat: Time.now.to_i
      }
      # the only way to generate this token is if you know the secret key
      TextEncryptor.encrypt(JSON.dump(payload))
    end

    # Exchange phone token to a phone,
    # later this phone can be used to sign-in / sign-up a user
    def phone_token_to_phone(phone_token)
      payload = begin
                  JSON.parse(TextEncryptor.decrypt(phone_token)).symbolize_keys
                rescue
                  raise StandardError, "The phone token is invalid"
                end
      # make sure the token hasn't expired yet
      issued_at = Time.at(payload[:iat])
      if issued_at < PHONE_TOKEN_EXPIRATION.ago
        raise StandardError, "The phone token is no longer valid"
      end
      payload[:phone]
    end

    private

    def phone_key(phone)
      "phone_verification_#{PhoneValidator.clean(phone)}"
    end

    def resend_key(phone)
      "#{phone_key(phone)}_resend"
    end

    def attempts_key(phone)
      "#{phone_key(phone)}_attempts"
    end

    def redis
      RedisClient.instance
    end
  end
end

You can read the full code at https://github.com/TheRusskiy/rails-phone-auth.

Thank you for reading!

p.s. I recommend against using SMS as a sole means of authentication for security-critical apps such as banking, admin dashboards and other systems where gaining access to a single account can give a lot of value, only use it in conjunction with passwords. A dedicated attacker can spoof SMS of a specific user.