Rails rate limiting without Rack::Attack
- Ruby on Rails
- Rails 8
- Security
Login forms, password resets and signup endpoints all need a throttle. For years that meant adding rack-attack, writing an initializer, and wiring up a Redis connection the middleware could reach. Enough setup that plenty of small apps just skipped it.
Rails 7.2 moved the basic case into Action Controller. DHH and Jean Boussier added rate_limit, and protecting a session controller is now one line:
class SessionsController < ApplicationController
rate_limit to: 10, within: 3.minutes, only: :create
end
Ten POST /session calls from one IP in three minutes. The eleventh gets a 429.
How it works
The whole implementation is about thirty lines in action_controller/metal/rate_limiting.rb. Worth reading, because the behaviour falls straight out of it.
rate_limit isn’t special machinery. It registers a before_action and forwards everything else:
def rate_limit(to:, within:, by: -> { request.remote_ip }, with: -> { raise TooManyRequests },
store: cache_store, name: nil, scope: nil, **options)
before_action -> { rate_limiting(...) }, **options
end
That **options is why only:, except:, if: and unless: all behave exactly as they do on any other filter. It’s the same callback chain.
The filter itself does three things.
It builds a key. The parts get joined, and compact drops whatever is nil:
cache_key = ["rate-limit", scope, name, by].compact.join(":")
scope defaults to controller_path, so a sessions controller counting by IP produces rate-limit:sessions:203.0.113.4.
It counts. One call, no read-then-write:
count = store.increment(cache_key, 1, expires_in: within)
increment is atomic on Redis and Memcached, so concurrent requests can’t race past the limit. Two details matter here. The store has to implement increment, and :null_store returns nil instead of a count, which is what a generated config/environments/test.rb sets. Tests need a real store to see any limiting at all:
# config/environments/test.rb
config.action_controller.cache_store = :memory_store
It reacts. If the count is over, it instruments and calls with::
if count && count > to
ActiveSupport::Notifications.instrument("rate_limit.action_controller", ...) do
instance_exec(&with)
end
end
Nothing in there halts the request. The halt comes from the before_action contract: a filter stops the chain when it sets a response or raises. So a with: that renders, redirects, calls head or raises will block the action, and one that only writes to the log won’t.
The window is fixed
expires_in gets attached when increment creates the key, and later hits don’t push it back. The window starts at the first request and expires on schedule.
The practical effect is that a caller can spend the full allowance at the end of one window and the full allowance at the start of the next. Against to: 3, within: 2.seconds, six requests inside 2.15 seconds all pass. Fine for a login form, where twenty attempts in six minutes is still nowhere near a brute force. Worth knowing if each request is expensive.
The options
| Option | Default | Does |
|---|---|---|
to: |
required | Requests allowed per window |
within: |
required | Window length, as an ActiveSupport::Duration |
by: |
-> { request.remote_ip } |
The identity the count is keyed on |
with: |
raises TooManyRequests |
Runs when the limit is exceeded |
store: |
config.action_controller.cache_store |
Anything implementing increment |
name: |
nil |
Distinguishes multiple limits in one controller |
scope: |
controller_path |
Shares one count across controllers |
by: and with: are evaluated with instance_exec inside the controller, so params, session, current_user and helpers are all in scope. Since Rails 8.1 both also accept a symbol naming a private method, which reads better than a multi-line lambda in a class body:
class SignupsController < ApplicationController
rate_limit to: 5, within: 1.minute, with: :too_busy
private
def too_busy
redirect_to root_url, alert: "Too many signups right now."
end
end
Keying on something other than the IP
IP is a weak identity behind a corporate NAT or a mobile carrier, where thousands of people share an address. For a password reset, the account is the better key:
class PasswordResetsController < ApplicationController
rate_limit to: 5, within: 15.minutes, only: :create,
by: -> { params.dig(:user, :email).presence&.downcase || request.remote_ip },
with: -> { render json: { error: "Too many requests" }, status: :too_many_requests }
end
The fallback isn’t decoration. A by: returning nil gets compacted out of the key, which drops every request without an email into one shared counter.
Two limits in one controller
Rails 8.0 added name: for stacking a burst limit under a longer one:
class MessagesController < ApplicationController
rate_limit to: 3, within: 10.seconds, name: "burst"
rate_limit to: 100, within: 1.hour, name: "hourly"
end
The name has to be there. Without it both limits build the same key from the controller path, and they share a single counter.
Rails 8.1 added the opposite knob. scope: pins several controllers to one count, which is how an API gets a budget that spans endpoints:
class ApiController < ActionController::API
rate_limit to: 1000, within: 1.hour, scope: "api"
end
Every controller inheriting from it now draws on the same thousand.
Against Rack::Attack
I’ve run both. They solve overlapping problems at different layers, and the layer is the whole distinction.
rate_limit runs inside the controller, after routing and instantiation. That’s what makes params and session available to by:, and it’s also what makes it the wrong tool for absorbing a flood: every refused request still costs a full Rails dispatch.
Rack::Attack sits in middleware, ahead of the router. It can drop traffic before Rails builds a controller, ban an IP range across the entire app, and do fail2ban-style escalation where repeat offenders get longer blocks.
rate_limit |
Rack::Attack |
|
|---|---|---|
| Runs | In the controller | In middleware |
| Rules live | Next to the action | In an initializer |
Sees params, session |
Yes | No, only the Rack env |
| Blanket IP blocklists | No | Yes |
| Escalating bans | No | Yes |
| Extra dependency | None | One gem |
The split I’d reach for: rate_limit for per-endpoint business rules, and Rack::Attack or the CDN in front of it for anything that needs to be rejected cheaply.
Most apps only ever wanted the first one.