Let's say on a website, you enter the username and password and it returns a message after 1 to 2 seconds that "The entered credentials are invalid". When you try to log in with a different username and password, the website responds within microseconds with the same message: "The entered credentials are invalid". You don't see any enumeration vulnerability here, do you? The website doesn't reveal whether or not the user exists.

But do you know an attacker can still figure that out?

Timing Based Enumeration Attacks

That's right! Based on the application's response time, an attacker can perhaps detect genuine usernames in a system and use that information to further plan password guessing or phishing attacks. What does the response "time" have to do with any of this?

Typically, when a user is present, we verify the password, which takes time. When the user is not present, we do not proceed further and immediately return the response. The attackers take advantage of this very fact.

authenticate_by in Rails 7.1

To address this vulnerability in a Rails application, Rails 7.1 has added a new method called authenticate_by. This method can be used along with has_secure_password and takes the same amount of time to authenticate a user regardless of whether the user is present in the database.

Let's explore how to use this method in a Rails 7.1 app using some examples.

You can call this class method on any model that uses has_secure_password. We have to pass the attributes that are needed to fetch the record along with the password attribute. It finds the record using the non-password attributes and then authenticates using the password attributes. If the authentication succeeds, it returns the object and otherwise returns nil.

This

User.find_by(username: "...")&.authenticate("...")

becomes

User.authenticate_by(username: "...", password: "...")

Examples

In the example, we have implemented Login functionality using 2 approaches:

  1. The traditional approach
  # app/controllers/sessions_controller.rb

  ...
    @auth_time = Benchmark.realtime do
      @user = User.find_by(username: params[:username])
      @status = @user && @user.authenticate(params[:password])
      respond_to do |format|
        format.turbo_stream
      end
    end
  ...
  1. Using the authenticate_by method in Rails 7.1
  # app/controllers/sessions_controller.rb

  ...
    @auth_time = Benchmark.realtime do
      @user = User.authenticate_by(user_params)
      @status = @user.present?
      respond_to do |format|
        format.turbo_stream
      end
    end
  ...
  
  private

  def user_params
    params.permit(:username, :password)
  end

For simplicity, we have printed the benchmark score for both approaches on the screen.
Now let's put on the attacker's hat and see if we can figure out which user exists in the system for each situation.

  1. Scenario 1 - When password is valid
    a. Sub-scenario 1: When username exists
    Screenshot-2022-05-24-at-16-47-53-https-__rails71-authenticateby-demo.athirakadampatt.repl.co-1

    User.authenticate_by(username: "ExistingUser", password: "********") # => (in 567.4ms)
    

    b. Sub-scenario 2: When username does not exist
    Screenshot-2022-05-24-at-16-49-19-https-__rails71-authenticateby-demo.athirakadampatt.repl.co

    User.authenticate_by(username: "NonExistingUser", password: "********") # => (in 539.2ms)
    
  2. Scenario 2 - When password is invalid
    a. Sub-scenario 1: When username exists
    Screenshot-2022-05-24-at-16-51-00-https-__rails71-authenticateby-demo.athirakadampatt.repl.co

    User.authenticate_by(username: "ExistingUser", password: "********") # => (in 571.8ms)
    

    b. Sub-scenario 2: When username does not exist
    Screenshot-2022-05-24-at-16-50-03-https-__rails71-authenticateby-demo.athirakadampatt.repl.co

    User.authenticate_by(username: "NonExistingUser", password: "********") # => (in 574.7ms)
    
  3. Scenario 3 - When password is empty
    a. Sub-scenario 1: When username exists
    Screenshot-2022-05-24-at-16-51-51-https-__rails71-authenticateby-demo.athirakadampatt.repl.co

    User.authenticate_by(username: "ExistingUser", password: "") # => (in 0.6ms - no queries executed)
    

    b. Sub-scenario 2: When username does not exist
    Screenshot-2022-05-24-at-16-52-39-https-__rails71-authenticateby-demo.athirakadampatt.repl.co

    User.authenticate_by(username: "NonExistingUser", password: "") # => (in 0.5ms - no queries executed)
    

Besides that, if you do not provide either the username or password arguments to authenticate_by it will raise ArgumentError.

User.authenticate_by(username: "ExistingUser") # => ArgumentError
User.authenticate_by(password: "********") # => ArgumentError

What we need to note in all the above scenarios is the consistency in time maintained by the authenticate_by method when the username exists and when it does not.

Final thoughts

As the PR author says here authenticate_by does not guarantee that the time taken for authentication would always be constant, especially if the username column is not backed by an index. Nevertheless, this addition is great for applications to avoid the possibility of enumeration attacks based on time.

Disclaimer

While writing this blog, Rails 7.1 is still under development. If you want to use this feature, you may please check the main branch here to see if it's released.

References

  1. Add authenticate_by when using has_secure_password
  2. Short circuit authenticate_by on empty password
  3. Rails adds authenticate_by method when using has_secure_password
  4. Time-Based Username Enumeration: Practical Or Not?
  5. Cover Image from Pixabay by Mohamed Hassan