Elixir/Phoenix Security: Rate Limits for Authentication with Hammer

Michael Lubas, 2023-02-22

Phoenix applications with user accounts share a common set of features: a login page, account creation, and password reset via an email. These systems are exposed to bot attacks, and developers should be aware of the risks that the public internet imposes on their software. This article will show a risk assessment of a Phoenix application’s login system, and how to use the Hammer library to rate limit the number of requests to each endpoint based on IP address.

1. What are the risks to an authentication system?

Consider the code generated by mix phx.gen.auth. Focus on the following:

  1. Username/password login
  2. New account creation
  3. Password reset

An attacker wants to cause harm to a business whose web application has these functions. What can the attacker do? This question is open ended, and varies from business to business. Here are the three risks common to the majority of cases:


1. Credential stuffing via automated login attempts

An attacker has a number of stolen username/password pairs, and writes a bot to do hundreds of login attempts per minute. The goal is unauthorized access to user accounts. For now, imagine the attacker is sending all these requests from a single IP address.

2. Automated account creation, for credit card fraud or general spam

If a site requires a credit card to signup, that’s not a defense against spam account creation. On the contrary, these sites are sought out by criminals doing credit card fraud, because they need a way to test thousands of stolen cards by making a small purchase. Even if the application does not have payment functionality, spam account creation can lead to thousands of “confirm your account” emails being sent, which may cause the transactional email provider to suspend service.

3. Abusing the password reset function to trigger a backend email provider ban

An attacker writing a bot to send out spam password reset emails seems low risk. This is incorrect, if the attacker triggers enough email send events, the application’s transactional email provider may terminate service, causing email for the entire application to stop working. That is a major incident.


In the example Phoenix application, nail, these functions correspond to the following HTTP requests:

POST /users/log_in
POST /users/register
POST /users/reset_password

Now that the risk from bots has been explained, we will limit number of requests one IP address can send in a 60 second period. This will be done via the Hammer library and conn.remote_ip.

2. Rate Limiting with Hammer

First, install hammer in the mix.exs file:

defp deps do
  [
    {:bcrypt_elixir, "~> 3.0"},
    ...
    {:hammer, "~> 6.1"}
  ]
end

Next, edit config/config.exs to use ETS as the backend:

config :hammer,
  backend: {Hammer.Backend.ETS,
            # 4 hours (240 minutes)
            [expiry_ms: 60_000 * 60 * 4,
            # 10 minutes
             cleanup_interval_ms: 60_000 * 10]}

With hammer configured, wrap the appropriate controller functions with a rate limit. First, limit login attempts based on IP address. This is done with the Hammer.check_rate function and conn.remote_ip.

POST /users/log_in - Request to rate limit

lib/nail_web/router.ex

scope "/", NailWeb do
  pipe_through [:browser, :redirect_if_user_is_authenticated]

  ...
  post "/users/log_in", UserSessionController, :create

lib/nail_web/controllers/user_session_controller.ex

def create(conn, params) do
  # 5 login attempts per minute
  case Hammer.check_rate(conn.remote_ip, 60_000, 5) do
    {:allow, _count} ->
      do_create(conn, params)
    {:deny, _limit} ->
      render(conn, "new.html", error_message: "Rate limited")
  end
end

def do_create(conn, %{"user" => user_params}) do
  %{"email" => email, "password" => password} = user_params

  if user = Accounts.get_user_by_email_and_password(email, password) do
    UserAuth.log_in_user(conn, user, user_params)
  else
    # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
    render(conn, "new.html", error_message: "Invalid email or password")
  end
end

This will limit the number of requests one IP can send to 5 per minute.

Next, limit new account creation:

POST /users/register - Request to rate limit

lib/nail_web/router.ex

scope "/", NailWeb do
  pipe_through [:browser, :redirect_if_user_is_authenticated]

  get "/users/register", UserRegistrationController, :new
  post "/users/register", UserRegistrationController, :create

lib/nail_web/controllers/user_registration_controller.ex

def create(conn, params) do
  # 5 attempts to create a new account per minute
  case Hammer.check_rate(conn.remote_ip, 60_000, 5) do
    {:allow, _count} ->
      do_create(conn, params)
    {:deny, _limit} ->
      render(conn, "new.html", error_message: "Rate limited")
  end
end

def do_create(conn, %{"user" => user_params}) do
  case Accounts.register_user(user_params) do
    {:ok, user} ->
      {:ok, _} =
        Accounts.deliver_user_confirmation_instructions(
          user,
          &Routes.user_confirmation_url(conn, :edit, &1)
        )

      conn
      |> put_flash(:info, "User created successfully.")
      |> UserAuth.log_in_user(user)

    {:error, %Ecto.Changeset{} = changeset} ->
      render(conn, "new.html", changeset: changeset)
  end
end

The code is similar to the function that rate limits login attempts.

Finally, rate limit password reset emails:

POST /users/reset_password - Request to rate limit

lib/nail_web/router.ex

scope "/", NailWeb do
  pipe_through [:browser, :redirect_if_user_is_authenticated]

  ...
  post "/users/reset_password", UserResetPasswordController, :create
end

lib/nail_web/controllers/user_reset_password_controller.ex

def create(conn, params) do
  # 5 password resets per IP per minute
  case Hammer.check_rate(conn.remote_ip, 60_000, 5) do
    {:allow, _count} ->
      do_create(conn, params)
    {:deny, _limit} ->
      conn
      |> put_flash(:error, "Rate limited")
      |> redirect(to: "/")
  end
end

def do_create(conn, %{"user" => %{"email" => email}}) do
  if user = Accounts.get_user_by_email(email) do
    Accounts.deliver_user_reset_password_instructions(
      user,
      &Routes.user_reset_password_url(conn, :edit, &1)
    )
  end

  conn
  |> put_flash(
    :info,
    "If your email is in our system, you will receive instructions to reset your password shortly."
  )
  |> redirect(to: "/")
end

If you are reading this article because your own IP based rate limiting is not working, due to the conn.remote_ip value being different from what you expected, the most likely reason is your application is behind a proxy. See the remote_ip library to fix this issue.


3. Limitations

Rate limiting on IP address is an excellent defense, however there are some situations where you may run into problems. For example, a large office where users share an IP address, and need to access the same web application at the same time may result in legitimate users triggering the rate limit.

Attackers also have tools to bypass IP based rate limiting, for example by proxying their traffic through a cloud service such as AWS API gateway. Paraxial.io is able to defend against this attack with a special plug that compares the conn.remote_ip against a radix trie of know cloud IP ranges, and is a great choice for businesses using Elixir in production.


Paraxial.io stops data breaches by helping developers ship secure applications. Get a demo or start for free.

Subscribe to stay up to date on new posts.