Rate Limit Your Rails API With Rack::Attack (Step-by-Step)
An unprotected Rails API is an invitation. Scrapers, credential stuffers, and that one client who polls every 200 milliseconds will find you. Rate limiting stops them before they eat your database alive.
This guide walks through adding rate limiting to a Rails 8 API using Rack::Attack, the most battle-tested throttling middleware for Rack apps. You’ll have working rate limits in about 15 minutes.
Install Rack::Attack
Add the gem:
# Gemfile
gem "rack-attack", "~> 6.7"
bundle install
Tell Rails to use the middleware. In Rails 8, add it to your application config:
# config/application.rb
config.middleware.use Rack::Attack
Configure the Cache Store
Rack::Attack needs a cache backend to track request counts. In production, you want something shared across processes. Redis is the standard choice, but Rails 8’s Solid Cache works too.
# config/initializers/rack_attack.rb
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1"),
expires_in: 10.minutes
)
If you’re already using Solid Cache and want to avoid a Redis dependency:
Rack::Attack.cache.store = Rails.cache
Just make sure your config.cache_store is set to :solid_cache_store or :redis_cache_store — not :null_store, or nothing gets tracked.
Set Up Basic Throttling
A throttle rule defines a rate limit: how many requests from the same source within a time window.
# config/initializers/rack_attack.rb
# Limit all requests by IP to 300 per 5 minutes
Rack::Attack.throttle("req/ip", limit: 300, period: 5.minutes) do |req|
req.ip
end
When a client exceeds 300 requests in a 5-minute window, subsequent requests get a 429 Too Many Requests response until the window resets.
Separate Limits for API Endpoints
Your API endpoints probably need different limits than your web pages. A login endpoint should be much stricter than a product listing:
# Strict limit on authentication endpoints
Rack::Attack.throttle("logins/ip", limit: 5, period: 20.seconds) do |req|
req.ip if req.path == "/api/v1/sessions" && req.post?
end
# Standard API limit
Rack::Attack.throttle("api/ip", limit: 100, period: 1.minute) do |req|
req.ip if req.path.start_with?("/api/")
end
The block returns a discriminator — the value that identifies who’s being throttled. Return nil to skip the rule for that request.
Throttle by API Key Instead of IP
IP-based throttling breaks down behind load balancers or when multiple clients share an IP. If your API uses token authentication, throttle by token:
Rack::Attack.throttle("api/token", limit: 1000, period: 1.hour) do |req|
if req.path.start_with?("/api/")
req.env["HTTP_AUTHORIZATION"]&.remove("Bearer ")
end
end
This gives each API client their own rate limit bucket.
Add Blocklists and Safelists
Sometimes you want to block known bad actors entirely, or exempt monitoring services:
# Block requests from specific IPs
Rack::Attack.blocklist("block bad actors") do |req|
bad_ips = Rails.cache.fetch("rack_attack:bad_ips", expires_in: 1.hour) do
BlockedIp.pluck(:address)
end
bad_ips.include?(req.ip)
end
# Never throttle requests from your monitoring
Rack::Attack.safelist("allow monitoring") do |req|
req.ip == ENV["MONITORING_IP"]
end
Safelists are checked first. If a request matches a safelist, it skips all throttles and blocklists.
Customize the 429 Response
The default 429 response is bare-bones. For an API, you want to return JSON with rate limit details:
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,
"X-RateLimit-Limit" => match_data[:limit].to_s,
"X-RateLimit-Remaining" => "0",
"X-RateLimit-Reset" => (now + retry_after).to_s
}
body = {
error: "Rate limit exceeded",
retry_after: retry_after
}.to_json
[429, headers, [body]]
end
The Retry-After header tells well-behaved clients exactly when to try again. The X-RateLimit-* headers follow the IETF draft standard that most APIs adopt.
Track Rate Limit Events
You want to know when throttling kicks in. Rack::Attack fires ActiveSupport notifications:
# config/initializers/rack_attack.rb
ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |name, start, finish, request_id, payload|
req = payload[:request]
Rails.logger.warn(
"[Rack::Attack] Throttled #{req.ip} on #{req.path} " \
"(matched: #{req.env['rack.attack.matched']})"
)
end
ActiveSupport::Notifications.subscribe("blocklist.rack_attack") do |name, start, finish, request_id, payload|
req = payload[:request]
Rails.logger.error("[Rack::Attack] Blocked #{req.ip} on #{req.path}")
end
Pipe these into your logging setup so they show up in your monitoring dashboard.
Test Your Rate Limits
Rack::Attack provides a test helper that clears the cache between tests:
# spec/requests/rate_limiting_spec.rb
require "rails_helper"
RSpec.describe "Rate limiting", type: :request do
before { Rack::Attack.cache.store.clear }
it "throttles excessive API requests" do
101.times { get "/api/v1/products", headers: { "REMOTE_ADDR" => "1.2.3.4" } }
expect(response.status).to eq(429)
expect(response.parsed_body["error"]).to eq("Rate limit exceeded")
end
it "allows requests under the limit" do
50.times { get "/api/v1/products", headers: { "REMOTE_ADDR" => "1.2.3.4" } }
expect(response.status).to eq(200)
end
end
Run a quick smoke test in development too:
# Hit an endpoint 10 times fast
for i in $(seq 1 10); do
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/v1/products
done
Production Considerations
Reverse proxy IP forwarding. If Rails sits behind Nginx or a load balancer, request.ip might always be 127.0.0.1. Make sure your proxy sets X-Forwarded-For and configure Rails:
# config/environments/production.rb
config.action_dispatch.trusted_proxies = ActionDispatch::RemoteIp::TRUSTED_PROXIES + [
IPAddr.new("10.0.0.0/8") # your internal network
]
Fail open, not closed. If your Redis goes down, Rack::Attack can’t count requests. By default it fails open (allows all traffic), which is the right choice. A broken rate limiter shouldn’t take down your API. You can verify this behavior:
# When cache store is unavailable, throttle returns nil (allow)
Rack::Attack.throttle("test", limit: 1, period: 1.minute) do |req|
req.ip
end
Cache expiry matters. Set your cache store’s expires_in to be at least as long as your longest throttle period. If you throttle per hour but your cache expires in 5 minutes, the counters reset and the throttle becomes useless.
Deploy gradually. Start with generous limits and monitor the 429 response rate in your logs. Tighten once you understand your traffic patterns. I’ve seen teams deploy aggressive rate limits on day one and lock out their own mobile app because it retried failed requests in a tight loop.
When Rack::Attack Isn’t Enough
Rack::Attack handles application-level rate limiting well. But if you’re dealing with DDoS-scale traffic, the requests still hit your Ruby process before getting rejected. For volumetric attacks, rate limit at the infrastructure level:
- Nginx
limit_req_zone— blocks requests before they reach Puma - Cloudflare / AWS WAF — blocks at the edge, before requests reach your server
- Kamal 2 with Thruster — the default proxy handles basic rate limiting at the Go layer
Layer your defenses. Rack::Attack catches abuse that makes it through your edge protection.
FAQ
How do I rate limit a Rails API without Redis?
Use Rails 8’s Solid Cache as your Rack::Attack cache store by setting Rack::Attack.cache.store = Rails.cache with config.cache_store = :solid_cache_store. Solid Cache stores rate limit counters in your database, so it works on any Rails deployment without additional infrastructure. Performance is slightly lower than Redis for high-traffic APIs, but adequate for most applications.
What rate limit should I set for a Rails API?
There’s no universal answer. Start with 100 requests per minute per IP for general API endpoints and 5 requests per 20 seconds for authentication endpoints. Monitor your 429 response rate for a week, then adjust. Your actual limits depend on your client behavior — a mobile app making 3 calls per screen load needs more headroom than a B2B integration that syncs hourly.
Does Rack::Attack work with Rails 8?
Yes. Rack::Attack 6.7+ works with Rails 8 and Ruby 3.3+. Add config.middleware.use Rack::Attack to your config/application.rb and configure it in an initializer. It integrates with all Rails 8 cache stores including Solid Cache.
How do I return rate limit headers in Rails?
Configure Rack::Attack.throttled_responder to return custom headers. Include Retry-After (seconds until reset), X-RateLimit-Limit (max requests), X-RateLimit-Remaining (requests left), and X-RateLimit-Reset (unix timestamp). These follow the IETF draft standard for HTTP rate limit headers.
Can I set different rate limits for different API clients?
Yes. Throttle by API key or token instead of IP address. Return the token from the throttle block as the discriminator: req.env["HTTP_AUTHORIZATION"]&.remove("Bearer "). Each unique discriminator gets its own counter, so each API client has an independent rate limit bucket.
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