Rate Limiting in Rails with Rack::Attack: A Production Configuration Guide
Rack::Attack is middleware that sits between your Rails app and incoming requests. It throttles, blocks, and tracks requests before they hit your controllers. If you’re running a Rails app in production without rate limiting, you’re one bot away from a bad day.
Here’s how to set it up properly in Rails 8, including the configuration pitfalls I’ve hit in production.
Installation and Basic Setup
Add the gem to your Gemfile:
gem "rack-attack", "~> 6.7"
Create the initializer at config/initializers/rack_attack.rb:
class Rack::Attack
# Use Rails cache as the backing store
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
# Throttle all requests by IP (300 requests per 5 minutes)
throttle("req/ip", limit: 300, period: 5.minutes) do |req|
req.ip unless req.path.start_with?("/assets")
end
end
That’s it for a minimal setup. But a minimal setup won’t save you when a scraper decides your API is its personal data warehouse.
Throttle Rules That Actually Work
The trick with rate limiting is being specific. One global throttle is too blunt — it’ll annoy legitimate users before it stops attackers. Layer your rules:
class Rack::Attack
### Throttle login attempts ###
throttle("logins/ip", limit: 5, period: 20.seconds) do |req|
req.ip if req.path == "/session" && req.post?
end
# Throttle login attempts by email (prevents credential stuffing)
throttle("logins/email", limit: 5, period: 20.seconds) do |req|
if req.path == "/session" && req.post?
req.params.dig("session", "email")&.downcase&.strip
end
end
### Throttle password resets ###
throttle("password_resets/ip", limit: 3, period: 15.minutes) do |req|
req.ip if req.path == "/password_resets" && req.post?
end
### API endpoints — tighter limits ###
throttle("api/ip", limit: 60, period: 1.minute) do |req|
req.ip if req.path.start_with?("/api/")
end
### General request throttle ###
throttle("req/ip", limit: 300, period: 5.minutes) do |req|
req.ip unless req.path.start_with?("/assets")
end
end
A few things to note about this configuration. The login throttle uses two discriminators: IP address and email. This catches both brute-force attacks from a single IP and distributed credential stuffing against a single account. The API throttle is separated from the general throttle because API endpoints are typically more expensive to serve — they hit the database, serialize JSON, and sometimes call external services.
Choosing a Cache Store
The cache.store setting controls where Rack::Attack tracks request counts. This choice matters more than most guides suggest.
MemoryStore works for single-server deployments:
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
The count resets when the process restarts, and each Puma worker has its own counter. If you’re running 4 Puma workers, an attacker effectively gets 4× your configured limit. For small apps behind a single server, this is usually fine.
Redis is what you want for multi-server deployments or when accuracy matters:
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
url: ENV["REDIS_URL"],
expires_in: 10.minutes
)
With Solid Cache now available in Rails 8, you might wonder if you can use it here. You can — Rack::Attack just needs any ActiveSupport::Cache::Store compatible backend. But there’s a catch: Solid Cache writes to your database, and rate limiting generates a lot of writes. Every single request triggers a cache increment. Under heavy traffic (which is exactly when rate limiting matters most), you’d be adding significant write load to your database at the worst possible moment.
Stick with Redis or MemoryStore for rate limiting. Use Solid Cache for application-level caching where write patterns are less intense.
Blocking Bad Actors
Throttling slows attackers down. Blocking stops them entirely:
class Rack::Attack
# Block requests from known bad IPs
blocklist("block bad ips") do |req|
blocked_ips = Rails.cache.fetch("blocked_ips", expires_in: 5.minutes) do
BlockedIp.pluck(:address)
end
blocked_ips.include?(req.ip)
end
# Block requests with suspicious user agents
blocklist("block scrapers") do |req|
bad_agents = %w[
AhrefsBot
SemrushBot
DotBot
MJ12bot
]
bad_agents.any? { |agent| req.user_agent&.include?(agent) }
end
# Always allow requests from your monitoring service
safelist("allow monitoring") do |req|
req.user_agent&.include?("UptimeRobot")
end
end
Safelists are evaluated before blocklists and throttles. If your monitoring service makes frequent requests, safelist it so it doesn’t trip your throttle and generate false alerts about downtime.
Custom Responses
The default 429 response is a bare Retry-After header and an empty body. For APIs, you’ll want something more informative:
Rack::Attack.throttled_responder = lambda do |request|
match_data = request.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
}
body = {
error: "Rate limit exceeded",
retry_after: retry_after
}.to_json
[429, headers, [body]]
end
Rack::Attack.blocklisted_responder = lambda do |request|
[403, { "Content-Type" => "text/plain" }, ["Forbidden\n"]]
end
I’ve seen apps return a 200 with an error message in the body when rate limited. Don’t do that. HTTP clients and CDNs understand 429 and will back off automatically. A 200 tells them everything is fine and to keep hammering.
Monitoring and Alerting
Rack::Attack publishes ActiveSupport notifications. Subscribe to them:
ActiveSupport::Notifications.subscribe(/rack\.attack/) do |name, start, finish, id, payload|
req = payload[:request]
case name
when "throttle.rack_attack"
Rails.logger.warn(
"Rate limited: #{req.ip} on #{req.path} " \
"(matched #{req.env['rack.attack.matched']})"
)
when "blocklist.rack_attack"
Rails.logger.warn("Blocked: #{req.ip} - #{req.user_agent}")
end
end
If you’re running OpenTelemetry, emit these as custom spans or metrics. A sudden spike in throttle events tells you something is wrong before your error rate does.
For production monitoring, track these metrics:
- Throttle events per minute by rule name
- Unique IPs being throttled
- Block events per minute
- Ratio of throttled to total requests
When throttled requests exceed 5% of total traffic, something is actively attacking your app or your limits are too aggressive.
Testing Your Configuration
Rack::Attack provides a test helper, but I find it easier to test with integration tests that actually hit the middleware:
# test/integration/rate_limiting_test.rb
require "test_helper"
class RateLimitingTest < ActionDispatch::IntegrationTest
setup do
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
Rack::Attack.reset!
end
test "throttles excessive login attempts" do
6.times do
post "/session", params: { session: { email: "test@example.com", password: "wrong" } }
end
assert_equal 429, response.status
end
test "allows requests under the limit" do
3.times do
post "/session", params: { session: { email: "test@example.com", password: "wrong" } }
end
assert_not_equal 429, response.status
end
end
Call Rack::Attack.reset! in your test setup. Without it, request counts leak between tests and you’ll get flaky failures that only happen when tests run in a specific order.
Handling Proxies and Load Balancers
If your Rails app sits behind Nginx, Cloudflare, or a load balancer, req.ip returns the proxy’s IP address — not the client’s. Every user shares one IP, and your throttle becomes useless.
Fix this with ActionDispatch::RemoteIp middleware (which Rails includes by default) and make sure your proxy forwards the right headers:
# Nginx configuration
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
Rails will use X-Forwarded-For to determine the real client IP. If you’re behind Cloudflare, also trust the CF-Connecting-IP header by adding Cloudflare’s IP ranges to config.action_dispatch.trusted_proxies.
# config/application.rb
config.action_dispatch.trusted_proxies = ActionDispatch::RemoteIp::TRUSTED_PROXIES + %w[
173.245.48.0/20
103.21.244.0/22
103.22.200.0/22
# ... full list at https://www.cloudflare.com/ips/
].map { |proxy| IPAddr.new(proxy) }
Exponential Backoff for Repeat Offenders
The standard throttle resets after the period expires. A determined attacker just waits and tries again. For login endpoints, exponential backoff works better:
# Escalating throttle: 5 attempts, then 25, then 50 per period
(1..3).each do |level|
throttle("logins/ip/level#{level}", limit: (5 * level**2), period: (20.seconds * level)) do |req|
req.ip if req.path == "/session" && req.post?
end
end
This gives casual mistypers room to retry while making sustained attacks increasingly painful. After the first 5 attempts in 20 seconds, the attacker hits the second throttle at 40 seconds, then the third at 60 seconds.
Common Mistakes
Not excluding health checks. If your load balancer pings /health every 5 seconds across 10 instances, that’s 120 requests per minute from the same IP. Safelist it.
Throttling by session instead of IP. Unauthenticated attackers don’t have sessions. Always include an IP-based throttle as your baseline.
Setting limits too low during launch. When your app hits Hacker News, legitimate traffic can look like an attack. Start with generous limits and tighten them based on actual traffic patterns.
Forgetting about background job endpoints. If your app exposes webhook URLs that trigger background jobs, throttle those separately. An attacker flooding your webhook endpoint can fill your job queue and crowd out real work.
FAQ
How does Rack::Attack differ from Rails 8’s built-in rate limiting?
Rails 8 added rate_limit at the controller level, which is useful for simple cases. Rack::Attack operates as Rack middleware, so it rejects requests before they reach your router, controllers, or database. For API protection and brute-force prevention, Rack::Attack is more efficient because rejected requests consume almost no application resources. Use Rails 8’s built-in rate_limit for business logic limits (like “5 exports per hour”) and Rack::Attack for infrastructure protection.
Should I use Rack::Attack behind Cloudflare’s rate limiting?
Yes, treat them as different layers. Cloudflare catches volumetric DDoS and bot traffic at the edge. Rack::Attack handles application-specific rules like login throttling and API rate limits that require understanding your URL structure. Cloudflare doesn’t know that /session is your login endpoint or that /api/v1/search is expensive to serve.
How do I rate limit authenticated API users differently?
Use the API key or user ID as the discriminator instead of (or in addition to) the IP:
throttle("api/token", limit: 1000, period: 1.hour) do |req|
if req.path.start_with?("/api/")
req.env["HTTP_AUTHORIZATION"]&.split(" ")&.last
end
end
This gives each API consumer their own limit bucket. Combine it with an IP-based throttle to catch unauthenticated abuse.
What’s the performance overhead of Rack::Attack?
Negligible for MemoryStore — it’s an in-memory hash lookup per request. With Redis, you add one network round-trip per request per matching rule. In practice, I’ve measured under 1ms overhead with Redis on the same network. The overhead of not having rate limiting (serving expensive requests to attackers) is orders of magnitude higher.
How do I temporarily disable rate limiting during load testing?
Set an environment variable and check it in your initializer:
unless ENV["DISABLE_RACK_ATTACK"]
Rack::Attack.throttle("req/ip", limit: 300, period: 5.minutes) do |req|
req.ip unless req.path.start_with?("/assets")
end
end
Never deploy with rate limiting disabled. I’ve seen teams forget to re-enable it after a load test and discover the gap only after an incident.
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation