Rails 8 rate_limit: throttling sign-in without a gem
September 14, 2026
More on Rails 8 authentication
- Rails 8 password reset: the token is the bcrypt salt
- Rails session expiry: the cookie store, the Session row, and the timeout neither gives you
- Rails JWT API authentication with ActionController::API
- Email confirmation in Rails 8, without a confirmation column
- Rails 8 native authentication vs Devise
- has_secure_password: everything one line gives you
Password guessing is the cheapest attack there is against a Rails application, and until Rails 8 the answer was
rack-attackand a middleware you configured somewhere else. The answer now is one line in the controller that owns the problem: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_actionand a counter. Here is the signature, fromActionController::RateLimiting:and here is the body that runs on each request:
Six lines, and every surprise in this article is visible in them.
scopedefaults tocontroller_path.bydefaults 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.minutesis 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
storedefaults tocache_store, which isconfig.action_controller.cache_store, which falls back toconfig.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: 10is 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:
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.
A null store's
incrementreturnsnil. Look at the condition again:if count && count > to. Withnilthe 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:
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(":").nameisnilby default andcompactdrops it. So tworate_limitcalls 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.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:raisesTooManyRequests, whichActionDispatch::ExceptionWrappermaps 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: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:
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_actionincrements 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-Afterheader, so a well behaved API client is not told when to come back. If that matters, set it in thewith:handler.And it is not a WAF. A distributed attempt from a thousand addresses meets a thousand counters of ten.
rate_limitcloses 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.