LaunchKit

Rails 8 rate_limit: throttling sign-in without a gem

September 14, 2026

Password guessing is the cheapest attack there is against a Rails application, and until Rails 8 the answer was rack-attack and a middleware you configured somewhere else. The answer now is one line in the controller that owns the problem:

class SessionsController < ApplicationController
  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { rate_limited }
end

The authentication hub covers who gets in. This is how often anyone is allowed to try.

What the macro actually is

The whole of Rails 8 rate limiting is a before_action and a counter. Here is the signature, from ActionController::RateLimiting:

def rate_limit(to:, within:, by: -> { request.remote_ip },
               with: -> { raise TooManyRequests },
               store: cache_store, name: nil, scope: nil, **options)

and here is the body that runs on each request:

by = by.is_a?(Symbol) ? send(by) : instance_exec(&by)

cache_key = ["rate-limit", scope, name, by].compact.join(":")
count = store.increment(cache_key, 1, expires_in: within)
if count && count > to

Six lines, and every surprise in this article is visible in them.

scope defaults to controller_path. by defaults to the remote IP. The counter lives in a cache store. And the expiry is on the key, which is the part worth slowing down for.

It is a fixed window, and that is not the same as a limit

store.increment(cache_key, 1, expires_in: within) sets the expiry when the key is created. The key dies three minutes after the first request, not three minutes after the last one, and the next request starts a fresh window at zero.

So the honest description of rate_limit to: 10, within: 3.minutes is not "ten requests in any three-minute period". It is "ten requests per three-minute bucket", and a client that spends ten at the very end of one bucket and ten at the start of the next has made twenty attempts in a couple of seconds.

For login throttling that is fine, and being clear-eyed about it is what lets you pick the number. Twenty guesses in a burst is not how passwords get broken. If you are rate limiting something where the burst itself is the damage, a fixed window is the wrong tool and the framework is not pretending otherwise.

The store decides whether any of this works

store defaults to cache_store, which is config.action_controller.cache_store, which falls back to config.cache_store. Three levels of default, and the value at the bottom changes the meaning of your limit.

A memory store counts inside one process. Two Puma workers, two counters, and to: 10 is an allowance of twenty. Four dynos of two workers each is eighty. Nothing warns you, the code is identical, and the limit you wrote down is not the limit you have.

That is why the environments differ here, and why development and production disagree on purpose:

# config/environments/production.rb
config.cache_store = :solid_cache_store

# config/environments/development.rb
config.cache_store = :memory_store

Solid Cache is a database table, so every worker on every dyno increments the same row and the allowance is the one you wrote. In development, where there is one process, a memory store is faster and counts the same.

The general rule underneath: Rails rate limiting is only as shared as its store. If the store is not visible to every process serving the endpoint, the limit is a suggestion.

The test environment is where rate limiting silently dies

The test configuration is the one people get wrong, and it fails in the worst direction: green specs, no protection.

# config/environments/test.rb
config.cache_store = :null_store

# Back `rate_limit` with a real (in-memory) store so throttling can be tested; the general
# cache stays a null store.
config.action_controller.cache_store = :memory_store

A null store's increment returns nil. Look at the condition again: if count && count > to. With nil the guard short-circuits, the limit never fires, and every rate limiting spec you write passes whether or not the macro is there at all.

The second line is the fix, and it is deliberately narrow: the general cache stays null so tests do not accidentally depend on caching, while the controller store is real so throttling can be asserted.

Then the counters leak. Many request specs sign in, sign-in is rate limited, and the eleventh example in a file starts failing for a reason that has nothing to do with what it tests. One hook settles it:

# spec/support/rate_limiting.rb
RSpec.configure do |config|
  config.before { ActionController::Base.cache_store.clear }
end

Debugging that failure without knowing the cause costs an afternoon, because the symptom is a redirect in a spec that never mentions throttling.

Two limits in one controller share a counter until you name them

cache_key = ["rate-limit", scope, name, by].compact.join(":").

name is nil by default and compact drops it. So two rate_limit calls on the same controller, for the same IP, build the identical key and increment the same integer. The stricter one wins and the other is decoration.

rate_limit to: 3,  within: 2.seconds, name: "short-term"
rate_limit to: 10, within: 5.minutes, name: "long-term"

The pairing is worth having. The short window stops a script, the long window stops patience, and neither alone does both.

The 429, and where to put it

The default with: raises TooManyRequests, which ActionDispatch::ExceptionWrapper maps to :too_many_requests. That is correct and it is also the public error page, which is a poor place to explain a temporary condition to a signed-in person.

Rather than repeat a response in every controller's with:, send them all to one place:

# ApplicationController
def rate_limited(message: t("shared.rate_limited"))
  redirect_to busy_path(error: message)
end
class BusyController < ApplicationController
  allow_unauthenticated_access
  allow_unonboarded_access

  def index
    @message = params[:error].presence || t("shared.rate_limited")

    respond_to do |format|
      format.html { render status: :too_many_requests }
      format.json { render json: { error: @message }, status: :too_many_requests }
    end
  end
end

Two details that are easy to miss. The controller must skip authentication, because the request being throttled is often the one trying to authenticate, and a redirect into a sign-in page that is itself rate limited is a loop. And it answers both HTML and JSON from one action, which is what makes the same macro usable for Rails API rate limiting without a second code path.

Once the response lives in one controller, applying the limit elsewhere is a line each:

rate_limit to: 10, within: 3.minutes, only: :create, with: -> { rate_limited }  # passwords
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { rate_limited }  # email confirmations
rate_limit to: 5,  within: 5.minutes, only: :create, with: -> { rate_limited }  # newsletter
rate_limit to: 5,  within: 10.minutes, only: :create, with: -> { rate_limited } # comments

Password reset and email confirmation matter as much as sign-in and get forgotten more often. Both send mail to an address supplied in the request, so an unthrottled endpoint is a free mail cannon pointed at anyone.

What rate_limit does not do

The macro counts attempts, not failures. The before_action increments before the action decides anything, so ten successful sign-ins spend the same allowance as ten wrong passwords. A person who mistypes, resets, and signs in on two devices is a handful of attempts into a limit sized for an attacker, so size it for that person rather than for the attacker.

It does not reset on success, for the same reason.

It sends no Retry-After header, so a well behaved API client is not told when to come back. If that matters, set it in the with: handler.

And it is not a WAF. A distributed attempt from a thousand addresses meets a thousand counters of ten. rate_limit closes the cheap hole, which is one machine trying a password list, and that is a real and worthwhile thing to close with one line.

The primitive underneath the sign-in it protects is has_secure_password, and the session the successful attempt produces is the subject of Rails session expiry, which is the other half of the question this one opens.

More on Rails 8 authentication

← All Rails 8 authentication articles