Skip to content
← All posts
5 min read Dawid Skłodowski

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.