How to: rate limit log in attempts while respecting privacy

Mark
Geek Culture
Published in
5 min readJun 30, 2021
Approaching log in limit message on Metamorphic. © Moss Piglet

Background

If you’ve been following along with the last few posts from me, then you’re aware that I’m currently developing a privacy-focused alternative to Big Tech social networks (or social media), called Metamorphic.

As I go along, I share a few things that I learn that I think are pretty cool or might be useful to other people.

In this case, I decided that I wanted to implement a limit to the amount of times someone can attempt to log in to their account, as a way to add some friction to potentially nefarious actors.

At the same time, I was also deciding on how to approach handling memories (images) that people upload in a ephemeral yet persistent manner. This meant uploading the encrypted images to a cloud provider for the storage load; and then handling the decryption, temporary storage, and in-app use of the memories on our server in real-time during a person’s session (people’s memories are asymmetrically encrypted and cannot be decrypted by anyone other than the owner of the memory and who they choose to share it with).

As I was mulling over both of these scenarios, I kept coming back to one thing, Erlang Term Storage (ETS).

So I began looking into ETS and discovered this wonderful blog post by Chris McCord at DockYard, and decided to give it a try for the log in rate limiter (and later a much more involved implementation for the ephemeral in-app memory decryption).

Prerequisites

For this post, we’re going to assume the following:

  • You have a Phoenix app up and running (~ 1.5.9)
  • You have phx_gen_auth installed (unless you’re on Phoenix 1.6+)
  • You have an authentication system up and running (or can understand the equivalent for your own scenario)
  • Optional: log in or login for naming (I switch between both when either naming a file or speaking in a person-facing scenario)
  • Optional: I use person/people to represent user/users

Credits

Special credit to DockYard and Chris McCord’s blog post, which is the giant upon whose shoulders we specifically stand for this brief guide (along with all those other giants… Erlang/Elixir/Phoenix etc.). If you are in need of technical help, I highly recommend checking them out.

And without further ado, let’s dive in.

Step 1

For our first step, let’s create a GenServer processor to handle the orchestration of our log in limiter.

In the web portion of your application code, let’s create a folder called “extensions” and put our max_login_processor.ex file in it. So, your file structure might look something like this:

your_app_web
│ ...
└───extensions
│ │ max_login_processor.ex
│ ...
# your_app_webb/extensions/max_login_processor.ex

You can name your folder whatever you like, for instance, maybe you would feel better about it being colocated with your authentication controllers. The use of the extensions folder is just to help organize our code.

So, what about that code?

For your max_login_processor.ex file you can copy Chris McCord’s example and make the following changes:

defmodule YourAppWeb.Extensions.MaxLoginProcessor do
@moduledoc """
A GenServer to limit log in attempts
to 5 every hour.
"""
use GenServer
# remove the require Logger or not
@max_per_hour 5
@sweep_after :timer.hours(1)
@tab :rate_limiter_requests
... def log(sid) do
case :ets.update_counter(@tab, sid, {2, 1}, {sid, 0}) do
count when count > @max_per_hour -> {:error, :rate_limited}
count -> {:ok, count}
end
end
def sweep_on_login(sid) do
case :ets.lookup(@tab, sid) do
[{session_id, _}] ->
:ets.delete(@tab, session_id)
[_] ->
:ok
end
end
## Server def init(_) do
# Remove debug logging if removing Logger from above
...
end
...
end

Okay, so very minor tweaks here:

  • changed uid -> sid to represent that we are using a person’s session_id
  • update the scheduled sweep to be 1 hour (@sweep_after :timer.hours(1))
  • add a sweep_on_login/1 function to clear the session’s log in count when a person logs in successfully (we don’t want people locking themselves out)

With that done, on to the second and final step.

Step 2

Open up your person_session_controller.ex file (or equivalent) and we are going to wrap our log in logic with a case statement:

#your_app_web/controllers/person_session_controller.exdefmodule YourAppWeb.PersonSessionController do
...
alias YourAppWeb.Accounts
alias YourAppWeb.Extensions.MaxLoginProcessor
...
def create(conn, %{"person" => person_params}) do
%{"email" => email, "password" => password = person_params
%{"_csrf_token" => session_token} = conn |> get_session()
case MaxLoginProcessor.log(session_token) do
{:ok, count} ->
case Accounts.get_person_by_email_and_password(email,
password) do
{:ok, person} ->
MaxLoginProcessor.sweep_on_login(session_token)
...
# Your errors here
# You can add logic to display remaining log in attempts
# alongside your existing auth error logic.
# Your auth error logic should be redirecting back
# to log in.
...
end
{:error, :rate_limited} ->
conn
|> put_flash(:error, "Your message here.")
|> redirect(to: Routes.person_session_path(conn, :new)
end
end
...
end

And that’s it!

Now, you can fine-tune the messages you display to people as I’ve noted in the comments, but this is the overarching structure for how it will work:

  1. If a person logs in successfully, then we make a call to our sweep_on_login/1 function, passing the person’s session_token (which we pattern matched on from the conn), to clear any existing log in attempts. This ensures that a person can come and go as they please without locking themselves out of their account.
  2. If a person has failed to log in too many times, then they will be locked out of their account for 1 hour (the time we scheduled the sweep to occur in our max_login_processor).

Explanation

We chose this approach for several reasons:

  • adds friction to brute force attempts on a person’s account
  • adds minimal friction to a person that accidentally locks themselves out
  • respects people’s privacy by using the session_id rather than an IP address
  • is “blind” as it is applied across all sessions regardless if an account exists

To elaborate just a bit, if someone is trying to brute force a person’s account (which only they have access to through their password), then the brute force attacker will have to constantly start new sessions and/or wait for an hour-long period. It’s not impossible, but it makes the attack take more work (which is the name of the game).

On the flip side, if a person accidentally locks themselves out, then they can either start a new session (say an incognito window) or wait for 1 hour. Again, we don’t want to make the person’s experience more of a pain on the off chance someone is trying to break into their account (which already has other defenses like a strong password, optional 2FA, and “blind” error messages).

Lastly, by using the session_id we respect people’s privacy by not pulling and logging (even if temporarily) their IP address which could be directly associated to their location (unless behind a good VPN). Further, when we couple the session_id approach with “blind” error messages (messages that don’t reveal whether or not someone has an account with us), it further protects people’s privacy because an attacker will get the same errors if they attempt to break into an existing or non-existing account.

Conclusion

So, thats it!

Hope it helps you if you were thinking about limiting log in attempts in your Elixir/Phoenix application and wanted to respect people’s privacy.

You could easily customize the max_login_processor to futher suit your specific needs.

If you’re interested in learning more about Metamorphic, then follow along with our company podcast or support us on Patreon and stay tuned — a transformation to our online life is coming soon!

Always open to improvements and thoughts, thank you!

💚 Mark

--

--

Mark
Geek Culture

Creator @ Metamorphic | Co-founder @ Moss Piglet