RUBY ON RAILS · 24 MIN READ ·

Rails Rack::Attack: Production Rate Limiting, IP Throttling, and Blocking Abuse

Rails Rack::Attack guide: production rate limiting, IP throttling, fail2ban, and blocking abusive traffic. Real config for Rails 8 APIs and login endpoints.

Rails Rack::Attack: Production Rate Limiting, IP Throttling, and Blocking Abuse

A client called me on a Sunday last November because their signup page had turned into a spam vending machine. Somebody had pointed a residential proxy botnet at POST /users and was creating 400 accounts a minute, each one triggering a welcome email, each welcome email nudging their SendGrid deliverability score a little further into the dirt. Their Puma workers were fine; their Postgres was fine; their reputation with every mailbox provider in North America was not fine. Two hours later the flood was gone, and the fix was ninety lines of Rails Rack::Attack config sitting in front of their existing app.

After nineteen years of Rails I have installed Rails Rack::Attack on almost every production app I have shipped, and I have watched the ones without it eventually pay for the omission — usually at 3 AM, usually right after a Product Hunt launch. This post is the exact Rails Rack::Attack setup I ship: throttles that actually match how attackers behave, fail2ban rules that cut off repeat offenders, safelists that keep your monitoring green, and the Redis-backed store that survives across your Puma workers.

Why Rails Rack::Attack Belongs in Every Production App

Rails Rack::Attack is a Rack middleware written by Aaron Suggs at Kickstarter that sits in front of your Rails app and inspects every incoming request. It gives you four verbs — throttle, blocklist, safelist, track — and a shared counter store, and that is enough to solve the vast majority of application-layer abuse: credential stuffing, signup spam, scraping, forgot-password flooding, and the classic “one customer’s misconfigured cron hits /api/v1/orders twelve times a second forever.”

The alternatives are worse in specific ways:

  • Nginx limit_req works, but it only sees IPs. It cannot rate-limit by email, API key, or logged-in user, and it cannot read your session cookie to distinguish “the user who is actually signed in” from “the bot pretending to be.” Every real limit you want to write eventually needs application context.
  • Cloudflare rate limiting is excellent at the edge but expensive per rule on the Pro plan and invisible to your Rails logs and metrics. Use it as a first line and Rails Rack::Attack as the second — they are complementary, not substitutes.
  • Doing nothing and hoping is what most Rails apps do until the first incident. Do not be that app.

The gem is tiny, boring, and has been production-hardened at Kickstarter, GitHub, and Basecamp scale for over a decade. Install it once, put five rules in a config file, and you have eliminated a whole class of Sunday-morning phone calls.

Installing Rack::Attack and Wiring the Cache Store

Add the gem and generate the initializer:

# Gemfile
gem "rack-attack", "~> 6.7"
gem "redis", "~> 5.0"  # if you're not already on it
# config/application.rb
config.middleware.use Rack::Attack

The single most important decision is the cache store. Rails Rack::Attack counts requests, and those counts have to be shared across every Puma worker on every server. The default Rails.cache is often :memory_store in production for developers who never bothered to configure it, which means each Puma process counts its own requests and a rule of “5 per second” becomes “5 per second per worker times sixteen workers times three servers” — effectively no limit at all.

Use Redis explicitly, and use a dedicated Redis or database number so throttle counters do not evict your fragment cache:

# config/initializers/rack_attack.rb
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
  url: ENV.fetch("RACK_ATTACK_REDIS_URL", ENV.fetch("REDIS_URL")),
  namespace: "rack-attack",
  expires_in: 1.hour,
  error_handler: ->(method:, returning:, exception:) {
    Sentry.capture_exception(exception, level: :warning)
    returning
  }
)

The error handler matters more than it looks. If Redis goes down and Rails Rack::Attack raises, every request in your app 500s. The block above returns the sentinel value (usually nil or 0), which makes throttling degrade to “allow the request” instead of “take the whole site down.” Failing open on a rate limiter is the correct choice; failing closed is how you turn a Redis blip into a full outage.

Throttling Login Attempts to Kill Credential Stuffing

The single highest-value Rails Rack::Attack rule for almost every app is throttling POST /users/sign_in (or whatever your login path is) by both IP and target email. Credential stuffing attacks — where an attacker replays leaked username/password pairs from another breach — are the daily reality of running an authenticated app in 2026, and a well-placed throttle stops them before the attacker even learns which accounts exist on your system.

# config/initializers/rack_attack.rb

# Throttle login attempts by IP: 5 per 20 seconds.
Rack::Attack.throttle("logins/ip", limit: 5, period: 20.seconds) do |req|
  req.ip if req.path == "/users/sign_in" && req.post?
end

# Throttle login attempts by target email: 5 per 60 seconds.
# This stops distributed attacks that rotate IPs but hammer one account.
Rack::Attack.throttle("logins/email", limit: 5, period: 60.seconds) do |req|
  if req.path == "/users/sign_in" && req.post?
    email = req.params.dig("user", "email").to_s.downcase.strip.presence
    email
  end
end

The two-dimensional throttle is what makes this actually work. IP-only limits fall to any attacker with 500 residential proxies. Email-only limits are trivially bypassed by rotating targets. Together they force the attacker into a corner: to sustain volume they need many IPs and to compromise a specific account they need many attempts against that email — and one of the throttles will trip either way.

Two subtleties that bite people. First, do not throttle by IP if your app sits behind a proxy without ActionDispatch::RemoteIp correctly configured — req.ip will be the load balancer’s IP and one throttle will silence your whole userbase. Verify with curl from an external network and read the value you actually get. Second, do the email.downcase.strip yourself; do not trust the frontend to normalize before it hits Rack, because the middleware runs before your controller.

API Throttling by API Key or Authenticated User

For JSON APIs the correct throttle discriminator is almost never the IP — it is the API key or the authenticated user’s ID. Every real API client eventually shares an outbound IP with hundreds of other tenants (AWS NAT, corporate proxies, Vercel edge functions), and throttling by IP will punish the polite customer who happens to share a /24 with a noisy one.

# Throttle authenticated API requests by API key.
# 300 requests per minute is roughly 5 rps sustained — generous for most REST clients.
Rack::Attack.throttle("api/key", limit: 300, period: 1.minute) do |req|
  if req.path.start_with?("/api/")
    req.env["HTTP_AUTHORIZATION"]&.sub(/^Bearer /, "")&.presence
  end
end

# Cheaper throttle for expensive endpoints — the ones that hit the LLM,
# do full-text search, or run reports. Tune per endpoint.
Rack::Attack.throttle("api/expensive", limit: 30, period: 1.minute) do |req|
  if req.path.start_with?("/api/") && EXPENSIVE_PATHS.match?(req.path)
    req.env["HTTP_AUTHORIZATION"]&.sub(/^Bearer /, "")&.presence
  end
end

EXPENSIVE_PATHS = %r{\A/api/v1/(search|reports|ai/)}

The pattern I ship most often is a two-tier throttle: a generous “everything” limit that stops runaway loops, and a much tighter limit on the specific endpoints where a single request costs you real money (LLM calls, PDF generation, Postgres queries over 100 ms). A customer who accidentally infinite-loops against /api/v1/search should hit the tight limit at request 30, not at request 300 after they have already blown through their monthly OpenAI budget.

If you use published API plans (Free/Pro/Enterprise), make the limit dynamic:

Rack::Attack.throttle("api/key", limit: ->(req) { req.env["rack.attack.limit"] || 60 }, period: 1.minute) do |req|
  next unless req.path.start_with?("/api/")

  key = req.env["HTTP_AUTHORIZATION"]&.sub(/^Bearer /, "")&.presence
  next unless key

  # Cache the limit lookup for 5 minutes so we don't hit Postgres on every request.
  limit = Rails.cache.fetch("api-limit/#{key}", expires_in: 5.minutes) do
    ApiKey.find_by(token: key)&.plan_rate_limit || 60
  end
  req.env["rack.attack.limit"] = limit
  key
end

The cache lookup is essential — a naive implementation that hits your ApiKey table on every request will make Rails Rack::Attack a bigger performance problem than the abuse it is stopping.

Signup Throttling and Forgot-Password Flooding

The story I opened this post with was signup spam, and the rule that fixed it in three lines is worth writing out:

# 3 signups per IP per hour — anything faster is almost certainly automation.
Rack::Attack.throttle("signups/ip", limit: 3, period: 1.hour) do |req|
  req.ip if req.path == "/users" && req.post?
end

# 20 signups per IP per day — catches the "slow drip" botnet variant.
Rack::Attack.throttle("signups/ip/day", limit: 20, period: 1.day) do |req|
  req.ip if req.path == "/users" && req.post?
end

# Forgot-password by email — otherwise you're an inbox flooder for anyone
# with a list of your users' emails.
Rack::Attack.throttle("forgot/email", limit: 3, period: 1.hour) do |req|
  if req.path == "/users/password" && req.post?
    req.params.dig("user", "email").to_s.downcase.strip.presence
  end
end

The dual-period signup throttle catches two different attack shapes with the same discriminator, which is a pattern worth internalizing. Attackers who bump into the “3 per hour” limit and back off will still get caught by the “20 per day” cap; attackers who go slow to evade the day-limit still get caught the moment they try to sprint. Whenever you write one throttle, write the slower companion.

Fail2ban: Automatic Blocking of Repeat Offenders

The nastier variant of every attack is the one where the attacker probes gently, learns your throttle limits by trial, and then rides at exactly 4 requests per 20 seconds forever. Rails Rack::Attack has a blocklist verb specifically for this — a rule that watches for a pattern and, once seen, blocks the source for a much longer window.

# Block anyone who trips the login throttle more than 3 times in 10 minutes.
Rack::Attack.blocklist("fail2ban/logins") do |req|
  Rack::Attack::Fail2Ban.filter("fail2ban-login-#{req.ip}", maxretry: 3, findtime: 10.minutes, bantime: 1.hour) do
    req.path == "/users/sign_in" && req.post? &&
      req.env["rack.attack.matched"] == "logins/ip"
  end
end

# Block anyone probing for common WordPress/PHP paths — nobody legitimate
# hits /wp-login.php or /.env on a Rails app.
BAD_PATHS = %r{\A/(wp-login\.php|wp-admin|\.env|\.git|phpinfo\.php|xmlrpc\.php|admin\.php)}
Rack::Attack.blocklist("fail2ban/scanners") do |req|
  Rack::Attack::Fail2Ban.filter("fail2ban-scan-#{req.ip}", maxretry: 1, findtime: 1.minute, bantime: 24.hours) do
    BAD_PATHS.match?(req.path)
  end
end

The scanner ban is one of my favorite rules because it is essentially free. A Rails app has no legitimate reason to serve /wp-login.php, and any IP asking for it is running a mass scanner that will next try /admin, /console, and eventually a directory-traversal payload against something real. Banning them for 24 hours after a single probe removes a chunk of the noise floor from your logs and takes the pressure off the more delicate throttles.

Safelisting: Don’t Ban Your Own Uptime Monitor

Every Rails Rack::Attack deployment I have ever set up has needed a safelist within the first week, usually because Pingdom or UptimeRobot started tripping a rule. Do the safelist before the throttles, and be specific:

# Never throttle health checks — otherwise a bad throttle rule brings down
# your entire load balancer's health perception.
Rack::Attack.safelist("allow/health") do |req|
  req.path == "/up" || req.path == "/health"
end

# Safelist your office and CI IPs. Store them in an ENV var, not the source,
# so ops can rotate them without a deploy.
OFFICE_IPS = ENV.fetch("RACK_ATTACK_SAFELIST_IPS", "").split(",").map(&:strip)
Rack::Attack.safelist("allow/office") do |req|
  OFFICE_IPS.include?(req.ip)
end

Do not safelist “logged-in users” — that is exactly the population most vulnerable to session hijacking, and safelisting them removes your protection at the moment you need it most. Do safelist the paths whose failure would confuse your alerting stack.

Returning Useful Responses, Not Just 429

The default Rails Rack::Attack response is a plain-text 429 Too Many Requests body, which is fine for humans but useless for API clients that want to know when to retry. Customize the response:

Rack::Attack.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])

  headers = {
    "Content-Type" => "application/json",
    "Retry-After" => retry_after.to_s,
    "RateLimit-Limit" => match_data[:limit].to_s,
    "RateLimit-Remaining" => "0",
    "RateLimit-Reset" => (now + retry_after).to_s
  }

  body = {
    error: "rate_limited",
    message: "Retry in #{retry_after} seconds",
    retry_after: retry_after
  }.to_json

  [429, headers, [body]]
end

Rack::Attack.blocklisted_responder = lambda do |_req|
  [403, { "Content-Type" => "application/json" }, [{ error: "forbidden" }.to_json]]
end

The RateLimit-* headers follow the IETF draft standard, and modern HTTP client libraries in every major language know how to read them and back off intelligently. Your own SDKs get this behavior for free.

Testing Rack::Attack Rules

Untested throttles are worse than no throttles — a broken rule will either lock out your real users or silently allow the attacks it was supposed to stop. RSpec tests belong in every Rails Rack::Attack config:

# spec/rack_attack_spec.rb
require "rails_helper"

RSpec.describe "Rack::Attack", type: :request do
  before do
    Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
    Rack::Attack.reset!
  end

  describe "login throttling" do
    let(:credentials) { { user: { email: "victim@example.com", password: "wrong" } } }

    it "throttles after 5 attempts from the same IP" do
      5.times do
        post "/users/sign_in", params: credentials
        expect(response.status).not_to eq(429)
      end

      post "/users/sign_in", params: credentials
      expect(response.status).to eq(429)
      expect(response.headers["Retry-After"]).to be_present
    end

    it "throttles by email even when the IP rotates" do
      5.times do |i|
        post "/users/sign_in",
          params: credentials,
          headers: { "REMOTE_ADDR" => "10.0.0.#{i}" }
      end

      post "/users/sign_in",
        params: credentials,
        headers: { "REMOTE_ADDR" => "10.0.0.99" }
      expect(response.status).to eq(429)
    end
  end

  describe "scanner ban" do
    it "blocklists an IP that requests /wp-login.php" do
      get "/wp-login.php"
      expect(response.status).to eq(403)

      # Confirm they are actually banned, not just the current request rejected.
      get "/"
      expect(response.status).to eq(403)
    end
  end
end

The Rack::Attack.reset! and fresh MemoryStore in before are what keeps these tests isolated — without them, a throttle triggered in one test leaks into the next and your suite becomes order-dependent noise. Related: my post on Rails system tests with Capybara has more on keeping HTTP-boundary tests deterministic in CI.

Observability: Log, Notify, Alert

Rails Rack::Attack emits ActiveSupport::Notifications events for every throttled, blocked, or safelisted request. Subscribe once and pipe them into your logging and metrics stack:

# config/initializers/rack_attack_notifications.rb
ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |_name, _start, _finish, _id, payload|
  req = payload[:request]
  Rails.logger.warn(
    event: "rack_attack.throttle",
    rule: req.env["rack.attack.matched"],
    ip: req.ip,
    path: req.path,
    user_agent: req.user_agent
  )
  StatsD.increment("rack_attack.throttled", tags: ["rule:#{req.env['rack.attack.matched']}"])
end

ActiveSupport::Notifications.subscribe("blocklist.rack_attack") do |_name, _start, _finish, _id, payload|
  req = payload[:request]
  Rails.logger.warn(event: "rack_attack.block", ip: req.ip, path: req.path)
  StatsD.increment("rack_attack.blocked")
end

Two things become possible once these events are flowing. First, you can build a Grafana panel of “throttles per rule per hour” and see attack waves in real time. Second, you can alert on the derivative — a sudden 20x spike in rack_attack.throttled is almost always the start of an incident, and getting a Pagerduty ping in the first minute buys you an hour of headroom over noticing when a customer complains. For deeper LLM-adjacent observability, see Rails LLM observability with Langfuse — the same instrumentation instincts apply.

Common Rails Rack::Attack Mistakes I Still See

A short list of the failure modes I have debugged more than once:

  • Using Rails.cache when it is :memory_store in production. Counters do not share across workers; the throttle does nothing. Verify with Rails.cache.class in production console.
  • Forgetting ActionDispatch::RemoteIp configuration behind a proxy. req.ip returns the load balancer’s IP and one throttle silences everyone. Set config.action_dispatch.trusted_proxies correctly.
  • Throttling by session ID or CSRF token. These rotate freely and give attackers free bypass. Throttle by the thing an attacker cannot easily rotate: authenticated user ID, API key, target email.
  • No error_handler on the cache. Redis blips propagate as 500s and take the whole app down. Fail open, always.
  • Rules that log but do not test. Every rule needs at least one RSpec test proving it fires, and one proving it does not fire on legitimate traffic.

Frequently Asked Questions

How is Rails Rack::Attack different from Rails 8’s built-in rate limiter?

Rails 8 shipped a small ActionController::RateLimit concern that is fine for one-off protection on a specific controller action. Rails Rack::Attack operates at the Rack layer before controllers load, supports blocklists and safelists, has fail2ban semantics, works across every framework component (Action Cable, Rails engines, mounted Rack apps), and has ten years of production hardening. Use the built-in for a quick controller-level cap; use Rails Rack::Attack for anything you would call a real protection layer.

Should I use Rack::Attack if I already have Cloudflare rate limiting?

Yes, and treat them as layered defenses. Cloudflare handles volumetric attacks and pattern-based bot detection at the edge cheaply. Rails Rack::Attack handles application-aware limits — throttling by API key, by target email, by authenticated user — that Cloudflare cannot see because they require decrypted session state. Both are cheap; run both.

What’s the right period for a Rails Rack::Attack throttle?

Short enough that a burst is caught immediately, long enough that a legitimate spike from a real user does not trip it. My defaults: login by IP at 5/20s, login by email at 5/60s, signup by IP at 3/hour, API by key at 300/minute. Always pair a fast throttle with a slow companion (e.g. 3/hour and 20/day for signups) to catch slow-drip attackers.

Does Rack::Attack work with Action Cable and WebSockets?

Yes for the initial HTTP upgrade request, but not for individual messages once the WebSocket is established — the middleware stack only runs on the initial request. Rate limiting per-message needs to live in your Action Cable channel, typically as a Redis-backed counter keyed on the connection identifier. Keep authentication-level throttling in Rails Rack::Attack and message-level throttling in the channel.


Need help hardening a Rails app against abuse, scraping, or credential stuffing before it hits production? TTB Software has been shipping production Rails systems for nineteen years, and Rack::Attack is the first thing we install. We are happy to review your setup or design one from scratch.

#rails-rack-attack #rails-rate-limiting #rails-api-throttling #rails-security #rails-fail2ban #prevent-brute-force-rails

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software