Isometric illustration of a stream of requests reaching a checkpoint with traffic cones and a clock, letting only some through to a server with a gauge

Rails rate limiting has two good options today. Since Rails 7.2, controllers have a built-in rate_limit macro (rate_limit to: 10, within: 3.minutes, only: :create) that answers 429 Too Many Requests once a client exceeds the limit. For anything that has to happen before routing, such as throttling every request per IP, banning scanners or adding a Retry-After header, Rack::Attack is still the tool. This guide sets up both for a production app: login and password-reset limits, per-user API limits, a shared counter store so limits hold across Puma workers, the real client IP behind proxies and CDNs, and tests that prove the limits work.


TL;DR

  • Use rate_limit in controllers for logins, signups, password resets and per-user API limits. Use Rack::Attack for global per-IP throttles and blocklists.
  • Both count in a cache store. It must be shared (Redis or Solid Cache): :memory_store counts per process, and :null_store, the development and test default, never limits anything.
  • Behind a CDN or load balancer, configure trusted proxies, or every visitor shares the proxy’s IP and one bucket.
  • Limit logins by IP and by email, so attackers can’t rotate IPs against one account or one IP against many accounts.
  • Return 429 with a Retry-After header, log every throttle, and test limits with a real cache store in the test environment.

Table of Contents

rate_limit or Rack::Attack: which one should I use?

Use rate_limit for limits tied to an action or a user, and Rack::Attack for limits that apply to every request before Rails routes it. Most production apps end up with both: rate_limit on the sensitive endpoints, and one broad Rack::Attack throttle per IP as a safety net.

Where to rate limit a Rails app: edge CDN, Rack::Attack middleware, controller rate_limit and a shared store, with a comparison of rate_limit and Rack::Attack

  rate_limit (Rails 7.2+) Rack::Attack
Where it runs before_action in a controller Rack middleware, before routing
Identifies clients by request.remote_ip by default, or any lambda (Current.user.id, API key) Any value from the Rack request (IP, header, param)
Blocklists / bans No Yes (blocklist, Fail2Ban, Allow2Ban)
Default response 429 Too Many Requests 429, customizable, optional Retry-After
Extra dependency None rack-attack gem
Cost of a rejected request Router + earlier callbacks run Middleware only

The edge (Cloudflare, Fastly) is a third layer for volumetric floods. It protects bandwidth, but it doesn’t know your users, so it doesn’t replace either of the two.

Prerequisites

  • Rails 7.2 or newer for rate_limit (examples use Rails 8.0 and Ruby 3.3). The name: option, needed for more than one limit in the same controller, arrived in Rails 8.
  • A shared cache store in production: Redis or Solid Cache.
  • For Rack::Attack, the rack-attack gem (6.8 at the time of writing).

Step 1: Rate limit logins and password resets with rate_limit

Put rate_limit in the controller and restrict it to the action that does the expensive or sensitive work. The Rails 8 authentication generator already adds this to SessionsController:

# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  allow_unauthenticated_access only: %i[ new create ]
  rate_limit to: 10, within: 3.minutes, only: :create,
    with: -> { redirect_to new_session_url, alert: "Try again later." }

  def new
  end

  def create
    if user = User.authenticate_by(params.permit(:email_address, :password))
      start_new_session_for user
      redirect_to after_authentication_url
    else
      redirect_to new_session_path, alert: "Try another email address or password."
    end
  end

  def destroy
    terminate_session
    redirect_to new_session_path
  end
end

That limit is per IP: 10 sign-in attempts per 3 minutes. An attacker with many IPs can still try passwords against one account, so add a second limit keyed by the email address being tried. With two limits in one controller, each needs a name::

# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  allow_unauthenticated_access only: %i[ new create ]

  rate_limit to: 10, within: 3.minutes, only: :create, name: "per-ip",
    with: -> { redirect_to new_session_url, alert: "Try again later." }

  rate_limit to: 5, within: 15.minutes, only: :create, name: "per-email",
    by: -> { params[:email_address].to_s.strip.downcase },
    with: -> { redirect_to new_session_url, alert: "Too many attempts for this account. Try again in 15 minutes." }

  # new, create, destroy as above
end

Normalize the email the same way your login does. Otherwise Bob@example.com and bob@example.com get separate counters.

Password resets send email, which costs money and can be abused to spam a victim’s inbox. Limit them per IP and per address:

# app/controllers/passwords_controller.rb
class PasswordsController < ApplicationController
  allow_unauthenticated_access

  rate_limit to: 5, within: 1.hour, only: :create, name: "reset-per-ip",
    with: -> { redirect_to new_password_path, alert: "Try again later." }

  rate_limit to: 3, within: 1.hour, only: :create, name: "reset-per-email",
    by: -> { params[:email_address].to_s.strip.downcase },
    with: -> { redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)." }

  # new, create, edit, update...
end

Pro Tip: in the per-email limit, show the same message as a successful request. A different message tells an attacker which addresses have accounts.

How does rate_limit count requests?

It’s a fixed window counter. On each request, Rails increments a cache key built from the controller, the limit’s name and the by: value, with an expiry of within. When the counter exceeds to:, the with: callable runs (by default, a 429 response). Because windows are fixed, a client can make up to twice the limit across a window boundary: 10 requests at the end of one 3-minute window and 10 at the start of the next. For login protection that’s fine. If you need a strict sliding window, use Rack::Attack or the edge.

Step 2: Rate limit an API per user or API key

For APIs, limit per account rather than per IP, so customers behind one corporate NAT don’t share a quota, and so one customer with many servers can’t exceed theirs. The by: lambda runs in the controller, so it can use the authenticated user:

# app/controllers/api/base_controller.rb
class Api::BaseController < ActionController::API
  include ActionController::HttpAuthentication::Token::ControllerMethods

  before_action :authenticate_api_key!

  rate_limit to: 1_000, within: 1.hour, name: "api-hourly",
    by: -> { @api_key.id }, with: :render_rate_limited
  rate_limit to: 60, within: 1.minute, name: "api-burst",
    by: -> { @api_key.id }, with: :render_rate_limited

  private

  def authenticate_api_key!
    @api_key = authenticate_with_http_token { |token, _| ApiKey.active.find_by(token: token) }
    render json: { error: "Unauthorized" }, status: :unauthorized unless @api_key
  end

  def render_rate_limited
    response.set_header("Retry-After", "60")
    render json: { error: "Rate limit exceeded" }, status: :too_many_requests
  end
end

The order matters. rate_limit registers a before_action, so it runs after authenticate_api_key! only because it’s declared after it. Unauthenticated requests are rejected before they reach the limit, which is what you want: they don’t consume a customer’s quota.

In a real app, store a digest of the token rather than the token itself, and look it up by digest.

Step 3: Use a shared store for the counters

Every limit is only as good as its counter store. By default, rate_limit uses the controller’s cache store, which is config.action_controller.cache_store if set, otherwise config.cache_store.

Store Shared across Puma workers and servers? Rate limiting works?
:null_store (dev default without dev:cache, test default) — No, nothing is ever counted
:memory_store No, one counter per process Partly: the real limit becomes to × processes
:file_store Only on one server Only for single-server apps
:solid_cache_store (Rails 8 production default) Yes, via the database Yes
:redis_cache_store Yes Yes, and the cheapest per increment

With 3 Puma workers and :memory_store, a limit of 10 is really 30, and it resets on every deploy. The Puma workers and threads guide explains why each worker has its own memory.

If your main cache is a store you sometimes clear, or you don’t want rate-limit writes competing with cache entries, give the counters their own Redis store:

# config/initializers/rate_limit_store.rb
RATE_LIMIT_STORE =
  if ENV["REDIS_URL"].present?
    ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"], namespace: "rate-limit")
  else
    ActiveSupport::Cache::MemoryStore.new
  end
# app/controllers/sessions_controller.rb
rate_limit to: 10, within: 3.minutes, only: :create, store: RATE_LIMIT_STORE

The fallback to MemoryStore keeps limits working in development and tests, and lets assets:precompile run in a Docker build without REDIS_URL. How to configure redis_cache_store itself (timeouts, error handling, maxmemory-policy) is in Rails Redis caching in production.

Step 4: Get the real client IP behind proxies

rate_limit uses request.remote_ip and Rack::Attack uses request.ip. Both skip private-network proxies, but they don’t trust public ones like Cloudflare. Behind a CDN, every request then appears to come from a handful of CDN IPs, and all your users share a few counters. The first busy hour, everyone gets 429s.

Tell Rails which proxies to trust. For Cloudflare, add its published ranges:

# config/initializers/trusted_proxies.rb
cloudflare_ranges = %w[
  173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22
  141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20
  197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13
  104.24.0.0/14 172.64.0.0/13 131.0.72.0/22
  2400:cb00::/32 2606:4700::/32 2803:f800::/32 2405:b500::/32
  2405:8100::/32 2a06:98c0::/29 2c0f:f248::/32
].map { |range| IPAddr.new(range) }

Rails.application.config.action_dispatch.trusted_proxies =
  ActionDispatch::RemoteIp::TRUSTED_PROXIES + cloudflare_ranges

Cloudflare publishes the current list at https://www.cloudflare.com/ips-v4 and /ips-v6. Refresh it occasionally.

Then check what Rails sees:

# app/controllers/debug_ip_controller.rb (temporary, remove after checking)
class DebugIpController < ApplicationController
  def show
    render plain: "remote_ip=#{request.remote_ip} ip=#{request.ip} xff=#{request.headers['X-Forwarded-For']}"
  end
end

Open it from your phone on mobile data and compare remote_ip with what a “what is my IP” site shows. They must match.

Never trust X-Forwarded-For from everywhere (for example by taking its first value yourself). Clients can set that header to anything, which turns your per-IP limit into no limit.

Step 5: Add Rack::Attack for global throttles and bans

Rack::Attack runs before routing, so it’s the right place for a broad per-IP throttle and for blocking scanners that hit /wp-login.php and /.env thousands of times a day.

# Gemfile
gem "rack-attack", "~> 6.8"

Rails inserts the middleware automatically. Configure it in an initializer:

# config/initializers/rack_attack.rb
class Rack::Attack
  # Shared counters across workers and servers.
  if ENV["REDIS_URL"].present?
    Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
      url: ENV["REDIS_URL"], namespace: "rack-attack"
    )
  end

  # Use Rails' remote_ip so the trusted proxy configuration from Step 4 applies.
  def self.client_ip(req)
    ActionDispatch::Request.new(req.env).remote_ip
  end

  # Health checks and local requests are never limited.
  safelist("health-and-local") do |req|
    req.path == "/up" || %w[127.0.0.1 ::1].include?(req.ip)
  end

  # Broad safety net: 300 requests per 5 minutes per IP, assets excluded.
  throttle("req/ip", limit: 300, period: 5.minutes) do |req|
    client_ip(req) unless req.path.start_with?("/assets/")
  end

  # Logins: per IP and per email, as a second layer to the controller limits.
  throttle("logins/ip", limit: 20, period: 5.minutes) do |req|
    client_ip(req) if req.path == "/session" && req.post?
  end

  throttle("logins/email", limit: 10, period: 15.minutes) do |req|
    if req.path == "/session" && req.post?
      req.params["email_address"].to_s.strip.downcase.presence
    end
  end

  # Ban scanners for an hour after 3 probes in 10 minutes.
  SCANNER_PATHS = %r{\A/(wp-admin|wp-login\.php|xmlrpc\.php|\.env|\.git|phpmyadmin)}i

  blocklist("fail2ban/scanners") do |req|
    Rack::Attack::Fail2Ban.filter("scanners-#{client_ip(req)}", maxretry: 3, findtime: 10.minutes, bantime: 1.hour) do
      req.path.match?(SCANNER_PATHS) || req.path.end_with?(".php")
    end
  end

  # 429 with Retry-After and a JSON body.
  self.throttled_responder = lambda do |req|
    match_data = req.env["rack.attack.match_data"]
    now = match_data[:epoch_time]
    retry_after = match_data[:period] - (now % match_data[:period])

    [429,
     { "content-type" => "application/json", "retry-after" => retry_after.to_s },
     [{ error: "Too many requests", retry_after: retry_after }.to_json]]
  end
end

Log every throttle and ban, so you can see what’s being limited and tune it:

# config/initializers/rack_attack_logging.rb
ActiveSupport::Notifications.subscribe(/rack_attack/) do |name, _start, _finish, _id, payload|
  req = payload[:request]
  next unless %w[throttle blocklist].include?(req.env["rack.attack.match_type"].to_s)

  Rails.logger.warn({
    event: name,
    rule: req.env["rack.attack.matched"],
    type: req.env["rack.attack.match_type"],
    ip: ActionDispatch::Request.new(req.env).remote_ip,
    path: req.path
  }.to_json)
end

Structured logs like this one are easy to search and alert on; the Rails log management guide shows how to ship them.

What limits should I use?

Start generous, log for a week, then tighten. These are reasonable starting points for a typical Rails app:

Endpoint Key Limit Why
POST /session (login) IP 10 per 3 min Rails 8 generator default
POST /session (login) email 5 per 15 min Stops distributed guessing on one account
POST /passwords (reset) email 3 per hour Each request sends an email
POST /registrations (signup) IP 10 per hour Slows fake account creation
API API key 60 per min, 1,000 per hour Burst + sustained quota
Every request IP 300 per 5 min rack-attack’s README example, catches crawlers and loops
Scanner paths IP Ban 1 h after 3 hits Nobody legitimate requests /wp-login.php on a Rails app

A normal page view triggers one HTML request plus Turbo Frames and fetches. With assets excluded, an active user rarely exceeds 30–60 requests per minute, so 300 per 5 minutes leaves room. If many of your users share one IP (schools, offices, mobile carriers), raise the per-IP numbers and rely more on per-account keys.

Verify it works

Check the Rack::Attack throttle from outside with a loop of plain GET requests, and watch the status codes change once the limit is reached:

for i in $(seq 1 305); do
  curl -s -o /dev/null -w "%{http_code}\n" https://example.com/
done | sort | uniq -c
#  300 200
#    5 429

curl -sI https://example.com/ | grep -i retry-after
# retry-after: 187

Don’t test the login limit with curl the same way. protect_from_forgery runs before rate_limit (it’s declared in ApplicationController), so a POST without an authenticity token gets 422 before the limit is ever counted. The InvalidAuthenticityToken guide explains that check. Test controller limits in an integration test instead, where forgery protection is off by default.

Then cover the limit with a test. The test environment uses :null_store, so give it a real store first:

# config/environments/test.rb
config.action_controller.cache_store = :memory_store
# test/controllers/sessions_rate_limit_test.rb
require "test_helper"

class SessionsRateLimitTest < ActionDispatch::IntegrationTest
  setup { ActionController::Base.cache_store.clear }

  test "blocks the 11th login attempt from the same IP within 3 minutes" do
    10.times do
      post session_path, params: { email_address: "nobody@example.com", password: "wrong" }
      assert_equal "Try another email address or password.", flash[:alert]
    end

    post session_path, params: { email_address: "nobody@example.com", password: "wrong" }
    assert_redirected_to new_session_url
    assert_equal "Try again later.", flash[:alert]

    travel 3.minutes + 1.second
    post session_path, params: { email_address: "nobody@example.com", password: "wrong" }
    assert_equal "Try another email address or password.", flash[:alert]
  end
end

If you passed an explicit store: RATE_LIMIT_STORE, clear that store in setup instead. travel only moves Ruby’s clock, which is enough for MemoryStore expiry; it doesn’t move Redis’s TTLs.

How Heroku, Render, Fly.io, Upsun and Enkihost handle rate limiting

None of the platforms rate-limits per user or per endpoint for you, so app-level limits are needed everywhere. What differs is what sits in front of the app.

Platform Built-in request rate limiting What it gives you What you still need
Heroku None at the router Router timeouts, no per-client limits rate_limit + Rack::Attack, or a CDN in front
Render No configurable app limits DDoS protection at the edge rate_limit + Rack::Attack
Fly.io No per-client limits concurrency soft/hard limits per Machine (load shedding, not per client) rate_limit + Rack::Attack
Upsun Per-IP limiting when behind its Fastly CDN (defaults documented as 100 req/min for users, 30 for bots) Edge 429s and a penalty box Per-user and per-endpoint limits in the app
Enkihost App-level Managed Redis included on Ignite and Blaze, with REDIS_URL injected rate_limit + Rack::Attack using that Redis

Fly’s concurrency settings deserve a note because they’re often confused with rate limiting. soft_limit and hard_limit control how many connections or requests one Machine accepts before Fly’s proxy sends traffic elsewhere or queues it. They protect the Machine from overload, not your login form from brute force.

Troubleshooting

Rate limiting does nothing in development or tests

The cache store is :null_store, which discards every write. Run bin/rails dev:cache in development, set config.action_controller.cache_store = :memory_store in test.rb, or pass an explicit store:.

Everyone gets 429 at the same time

All requests share one key, almost always because remote_ip is the proxy’s or CDN’s IP. Check with the debug action from Step 4 and configure trusted_proxies.

Limits are higher than configured

The store is per process (:memory_store), so the effective limit is multiplied by your number of Puma workers and servers. Switch to Redis or Solid Cache.

Two limits in one controller behave like one

Two rate_limit calls in the same controller without name: share a cache key, so they count into the same counter. Give each a distinct name: (Rails 8+). On Rails 7.2, combine them into one limit or move the second to Rack::Attack.

Redis errors take the site down

If Redis is unreachable, redis_cache_store logs the error and returns nil by default, so rate_limit fails open and requests go through. Rack::Attack also fails open on Redis connection errors. That’s usually the right trade-off for an app, but monitor Redis so “fails open” doesn’t silently become “no limits for a week”.

Health checks get throttled

Your platform’s health check hits /up from a fixed IP many times a minute. Safelist /up in Rack::Attack, as in Step 5, and don’t put rate_limit in Rails::HealthController.

Author Perspective: limits are a product decision

The first time I added rate limiting, I copied numbers from a blog post and locked out a client’s whole office on a Monday morning, because forty people shared one IP. Since then I treat limits as product decisions, not security settings. I start with logging-only (a Rack::Attack track rule instead of throttle), look at a week of real traffic, and then set limits a comfortable margin above what real users do. Per-account limits for anything behind login, per-IP limits only as a broad safety net, and a ban list for the obvious scanner noise. The scanner ban alone usually cuts a surprising share of log volume on a small app.

Rate limiting on Enkihost

Rate limiting needs a fast, shared counter store, and that’s the part a small app most often lacks. On Enkihost:

Enkihost

  • Managed Redis included on Ignite (128 MB) and Blaze, with REDIS_URL injected, so the RATE_LIMIT_STORE and Rack::Attack configuration above work without extra setup.
  • Managed PostgreSQL included too, if you prefer Rails 8’s Solid Cache as the counter store.
  • Per-app resource isolation, so a scanner hammering another app doesn’t eat the CPU and memory allocated to yours, and your own limits decide what reaches your code.
  • Zero-downtime deploys, so tuning limits after a week of logs is a routine deploy.
  • Plans: Ignite at 9 EUR per month after a 14-day free trial (2 apps, 1.0 shared vCPU, 2 GB RAM), or Blaze at 29 EUR with five apps, 5 GB of RAM, CPU priority and two Redis instances, so a shared Redis store keeps limits consistent across your apps.

See enkihost.com.

FAQ

What is the difference between rate_limit and Rack::Attack in Rails?

rate_limit is built into Rails 7.2 and later and runs as a before_action in a controller, so it can limit by current user, API key or any value the controller knows. Rack::Attack is middleware that runs before routing, so it can throttle every request by IP, block scanners and ban abusive clients. Many apps use both.

Does Rails rate_limit work with multiple Puma workers?

Only if the cache store is shared. rate_limit counts requests in the controller’s cache store, so with Redis or Solid Cache all workers and servers share the counters. With memory_store, each worker keeps its own count and the real limit is multiplied by the number of processes.

What HTTP status does Rails return when a rate limit is exceeded?

By default, 429 Too Many Requests. You can change the response with the with option, for example to redirect back to the login form with a flash message, or to render a JSON error with a Retry-After header for API clients.

How do I rate limit by user instead of IP in Rails?

Pass a by lambda to rate_limit that returns the user or API key identifier, for example by: -> { Current.user.id }. Declare the authentication before_action before rate_limit, so the user is loaded when the limit runs and unauthenticated requests are rejected without consuming anyone’s quota.

Can I use Rails rate_limit without Redis?

Yes. Rails 8 apps use Solid Cache in production by default, which stores counters in the database and is shared across processes and servers. Memory store also works but only per process, and the development and test default, null store, does not count anything.

Sources