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:
- 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
...
- 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.
-
Scenario 1 - When password is valid
a. Sub-scenario 1: When username exists
User.authenticate_by(username: "ExistingUser", password: "********") # => (in 567.4ms)
b. Sub-scenario 2: When username does not exist
User.authenticate_by(username: "NonExistingUser", password: "********") # => (in 539.2ms)
-
Scenario 2 - When password is invalid
a. Sub-scenario 1: When username exists
User.authenticate_by(username: "ExistingUser", password: "********") # => (in 571.8ms)
b. Sub-scenario 2: When username does not exist
User.authenticate_by(username: "NonExistingUser", password: "********") # => (in 574.7ms)
-
Scenario 3 - When password is empty
a. Sub-scenario 1: When username exists
User.authenticate_by(username: "ExistingUser", password: "") # => (in 0.6ms - no queries executed)
b. Sub-scenario 2: When username does not exist
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.