Rate Limiting a Phoenix API
June 16, 2015
elixir
In my spare time, I’ve been working on a little Phoenix project that involves a JSON API. Developers frequently neglect rate limiting when they build an API, assuming they are even aware that it is a best practice.
It’s true that in many cases rate limiting isn’t worth the effort, but when it comes to authentication, it definitely is. For example, the recent high-profile iCloud security breach which released celebrity photos in to the internet could have been prevented had Apple implemented rate limiting on one of their authentication APIs. This would have prevented the brute-force attack that the hackers used to guess the celebrities’ passwords.
My little API isn’t likely to hold any sensitive information, but I decided to rate limit my authentication API anyway. Here’s how I did it.
Introducing Plugs
In Phoenix, the request lifecycle is handled through “plugs”, which are simple functions or modules that take a connection, modify it, and then return the modified connection. Plugs come from an Elixir core project, aptly named “Plug”.
If you’re familiar with Ruby, plugs work very similarly to Rack middleware. However, instead of being a rare form of voodoo like Rack sometimes is to Ruby developers, plugs are very accessible in Elixir/Phoenix, and are used often.
In its simplest form, a plug looks like this:
def my_custom_plug(conn, options \\ []) do
conn
end
This plug does nothing. It just takes a connection and returns it, which is all a good little plug has to do. To use this plug in a Phoenix controller, you just have to import the function and add it to the controller’s plug pipeline:
defmodule MyApp.Controller do
use MyApp.Web, :controller
import MyPlug, only: [my_custom_plug: 2]
plug :my_custom_plug
plug :action
end
The my_custom_plug
function will then run on the connection before the
controller action is called.
The RateLimit Plug
The Plug interface is ideal for rate limiting because it allows us to intercept the request before any real work is done. This prevents rejected requests from producing any serious load on the server.
For my plug, I used a library called ExRated to do the brunt of the rate limiting work. It has a simple API:
ExRated.check_rate("bucket_name", interval_in_milliseconds, maximum_requests)
# => {:ok, count} or {:fail, count}
Using this, it’s very easy to construct a plug:
defmodule MyApp.RateLimit do
import Phoenix.Controller, only: [json: 2]
import Plug.Conn, only: [put_status: 2]
def rate_limit(conn, options \\ []) do
case check_rate(conn, options) do
{:ok, _count} -> conn # Do nothing, allow execution to continue
{:fail, _count} -> render_error(conn)
end
end
defp check_rate(conn, options) do
interval_milliseconds = options[:interval_seconds] * 1000
max_requests = options[:max_requests]
ExRated.check_rate(bucket_name(conn), interval_milliseconds, max_requests)
end
# Bucket name should be a combination of ip address and request path, like so:
#
# "127.0.0.1:/api/v1/authorizations"
defp bucket_name(conn) do
path = Enum.join(conn.path_info, "/")
ip = conn.remote_ip |> Tuple.to_list |> Enum.join(".")
"#{ip}:#{path}"
end
defp render_error(conn) do
conn
|> put_status(:forbidden)
|> json(%{error: "Rate limit exceeded."})
|> halt # Stop execution of further plugs, return response now
end
end
You can then use this plug to rate limit specific actions in your controller like so:
import MyApp.RateLimit
plug :rate_limit, max_requests: 5, interval_seconds: 60 when action in [:create]
plug :action
Customizing for Authentication
The RateLimit plug as it currently exists is great for most use cases, but it still isn’t adequate for authentication. This is because limiting based on IP address still allows an attacker to make thousands of attempts on a user account from different computers.
To protect against this, we need to rate limit login attempts based on the username, not the IP address. The plug needs an optional :bucket_name
parameter, so that this can be customized.
defp check_rate(conn, options) do
interval_milliseconds = options[:interval_seconds] * 1000
max_requests = options[:max_requests]
bucket_name = options[:bucket_name] || bucket_name(conn)
ExRated.check_rate(bucket_name, interval_milliseconds, max_requests)
end
We then add a new function to our controller, setting the dynamic :bucket_name
to “authorization:{email}”:
def rate_limit_authentication(conn, options \\ []) do
options = Dict.merge(options, [bucket_name: "authorization:" <> conn.params.email])
MyApp.RateLimit.rate_limit(conn, options)
end
And use this instead of the old :rate_limit
plug:
import MyApp.RateLimit
plug :rate_limit_authentication, max_requests: 5, interval_seconds: 60
plug :action
This will then rate limit requests against the user’s email address, not the IP address that the requests are coming from. So, no matter how many IPs the hacker has access to, he can’t get around the rate limit.
The Finished Plug
defmodule MyApp.RateLimit do
import Phoenix.Controller, only: [json: 2]
import Plug.Conn, only: [put_status: 2]
def rate_limit(conn, options \\ []) do
case check_rate(conn, options) do
{:ok, _count} -> conn # Do nothing, pass on to the next plug
{:fail, _count} -> render_error(conn)
end
end
defp check_rate(conn, options) do
interval_milliseconds = options[:interval_seconds] * 1000
max_requests = options[:max_requests]
bucket_name = options[:bucket_name] || bucket_name(conn)
ExRated.check_rate(bucket_name, interval_milliseconds, max_requests)
end
# Bucket name should be a combination of ip address and request path, like so:
#
# "127.0.0.1:/api/v1/authorizations"
defp bucket_name(conn) do
path = Enum.join(conn.path_info, "/")
ip = conn.remote_ip |> Tuple.to_list |> Enum.join(".")
"#{ip}:#{path}"
end
defp render_error(conn) do
conn
|> put_status(:forbidden)
|> json(%{error: "Rate limit exceeded."})
|> halt # Stop execution of further plugs, return response now
end
end
Performance
The performance of this plug is really stunning. On my relatively modest Macbook Pro, I’m seeing sub-millisecond response times when the rate limit kicks into effect. Rails devs, let that sink in for a second.
Sub-millisecond response times…
So, using this plug doesn’t slow down valid requests in any measurable way, and with response times like that, it will be hard to overwhelm your API with bogus traffic.
Conclusion
I was pleasantly surprised at how easy this was to implement and how elegant the solution ended up being. Let’s review what we implemented:
- A rate limiter that can be customized per action and per controller.
- Without any external dependencies. (I’m looking at you, Redis)
- With sub-millisecond response times.
Elixir and Phoenix continue to impress me! This exercise also made me think that I should look more into Rack in Ruby-land. It’s probably under-utilized for problems like this. Perhaps that will be the subject of a future post. For now though, so long!