Rate limiting and abuse prevention with Rack::Attack
Every public app gets hammered — credential-stuffing on login, scrapers on your catalog, brute force on your API. A deep-dive on Rack::Attack: throttling, blocklisting, and fail2ban-style tracking, applied at the right layer to keep abuse off your app.
The moment an app is public, it is a target. Bots try stolen passwords against your login, scrapers pull your catalog as fast as they can, someone brute-forces your API or your password-reset endpoint. None of this is exotic — it is the constant background noise of the internet — and an app with no defences will, eventually, have an outage, a breach, or a stolen dataset because of it. Rack::Attack is the standard Ruby answer: a piece of Rack middleware that throttles, blocks, and tracks abusive requests before they reach your application. Here is how to use it well.
Why middleware is the right layer
Recall the Rack middleware discussion: middleware runs before your controllers, sees every request, and can short-circuit one without ever touching your app. That is exactly where abuse prevention belongs. You want to reject the thousandth login attempt from one IP before it runs a controller, hits the database, and consumes a worker — the whole point is to stop abuse cheaply, at the edge, not to let it in and handle it expensively. Rack::Attack is middleware for precisely this reason, and it uses a cache store (Redis or Memcached) to count requests across your servers, so the limits hold for your whole fleet, not per-process.
# config/initializers/rack_attack.rb
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"])
Throttling: the core tool
The workhorse is the throttle — allow N requests per period per key, and reject the rest. You define what to count and how to key it:
# general traffic: 300 requests / 5 min per IP
Rack::Attack.throttle("req/ip", limit: 300, period: 5.minutes) do |req|
req.ip
end
# login attempts: 5 per 20s per IP — credential stuffing defence
Rack::Attack.throttle("logins/ip", limit: 5, period: 20.seconds) do |req|
req.ip if req.path == "/login" && req.post?
end
# login attempts per ACCOUNT, not just IP — stops distributed attacks on one user
Rack::Attack.throttle("logins/email", limit: 5, period: 20.seconds) do |req|
req.params["email"].to_s.downcase.presence if req.path == "/login" && req.post?
end
The block returns a discriminator — the key to count by — or nil to skip. The discriminator
is where the thought goes. Throttling logins by IP stops one machine hammering you; but an
attacker with a botnet spreads attempts across thousands of IPs, so you also throttle by the
target (the email being attempted), which catches a distributed attack on one account that
per-IP limits would miss. Good rate limiting usually means several throttles keyed different
ways, each catching an attack shape the others don’t.
Blocklists and safelists
Beyond throttling, you can outright block or allow requests:
# always allow your office / monitoring IPs — never throttle yourselves
Rack::Attack.safelist("allow/office") { |req| req.ip == "203.0.113.4" }
# block known-bad actors outright
Rack::Attack.blocklist("block/bad-ip") { |req| BLOCKED_IPS.include?(req.ip) }
# block obvious garbage — scanners probing for /wp-admin, .php, etc.
Rack::Attack.blocklist("block/scanners") do |req|
req.path =~ /\.(php|asp)$|wp-admin|wp-login/
end
Safelists run first and exempt trusted sources (crucial — you do not want a throttle to lock out your own monitoring or office during an incident). Blocklists reject known-bad traffic before any throttle counting even happens, which is the cheapest possible rejection.
Fail2ban-style tracking: escalating response
The most powerful pattern is Fail2Ban: track a pattern of suspicious behaviour and, once a source crosses a threshold, block it for a longer cooldown. This is how you respond to an attacker rather than a burst — a single user who trips a limit once gets a brief throttle, but a source that repeatedly probes gets banned outright for a while:
Rack::Attack.blocklist("fail2ban/probes") do |req|
Rack::Attack::Fail2Ban.filter("probes-#{req.ip}", maxretry: 5, findtime: 10.minutes, bantime: 1.hour) do
# "true" = this request looks malicious (e.g. hitting known-attack paths)
suspicious_path?(req.path)
end
end
Five suspicious requests in ten minutes earns a one-hour ban. This escalation — a light touch for the occasional trip, a heavy one for persistent abuse — is what separates a smart defence from a blunt one that also annoys legitimate users.
Respond thoughtfully
When you throttle someone, what you return matters. The right status is 429 Too Many Requests,
ideally with a Retry-After header so well-behaved clients know to back off, and a clear message
for humans who hit a limit legitimately:
Rack::Attack.throttled_responder = lambda do |req|
[429, { "Content-Type" => "text/plain", "Retry-After" => "20" }, ["Too many requests. Please wait.\n"]]
end
Returning a proper 429 (not a generic 500) is both correct and kinder — a legitimate user who briefly hits a limit gets an honest “slow down”, and a polite bot or API client backs off automatically.
Tune to avoid false positives
A real caution: rate limits that are too aggressive lock out legitimate users — a busy office behind one NAT IP, a mobile network sharing addresses, a power user. Set limits generously enough that normal use never trips them while abuse does, monitor what you are throttling (Rack::Attack fires notifications you can log), and watch for false positives after deploying new rules. Start conservative, observe, and tighten — a rate limiter that blocks real customers is worse than the abuse it was meant to stop.
Verdict
Every public app is under constant low-grade attack, and Rack::Attack is the right tool to handle
it at the right layer — middleware that throttles, blocks, and bans abusive requests before they
cost you a controller, a query, or a worker. Throttle the sensitive endpoints (login, signup,
password reset, API) keyed several ways — by IP and by target — to catch both bursts and
distributed attacks; safelist your own infrastructure; blocklist obvious garbage; and use
Fail2Ban-style escalation to punish persistence rather than the occasional trip. Respond with a
proper 429 and Retry-After, back it with a shared Redis store so limits hold across your fleet,
and tune carefully to avoid locking out real users. It is a small initializer that turns the
constant background abuse of the internet from a threat into a non-event.