Introduction
Every production application eventually faces abuse: scrapers hammering your API, brute-force login attempts, or bad actors flooding sign-up endpoints. Rate limiting in Rails is a practical guide for developers that covers how to protect your endpoints from these common threats. In this article, we'll walk through Rails 8's built-in tools and the industry-standard Rack::Attack gem to build a defense strategy that fits your application's needs.
Why rate limiting matters
Without rate limiting, a single malicious request loop can consume your database connections, spike your AWS bill, and degrade service for legitimate users. We've all seen the aftermath: a spike in traffic from a single IP, signup endpoints flooded with test accounts, or API endpoints returning timeouts because they're processing thousands of requests per second from one source.
Rate limiting acts as a gatekeeper. It doesn't eliminate bad actors, but it makes abuse expensive enough that casual attackers move on to easier targets. Combined with monitoring and alerting, it gives your team breathing room to respond to coordinated attacks.
Built-in rate_limit in Rails 8 controllers
Rails 8 introduced a native rate_limit method directly in controllers, built on top of cache stores like Redis or Memcached. This is a game-changer for simple, per-endpoint rules without extra dependencies.
Here's how to add rate limiting to a controller action:
class ApiController < ApplicationController
rate_limit to: 100, within: 1.minute, by: -> { request.remote_ip }
def show
@resource = Resource.find(params[:id])
render json: @resource
end
endIn this example, we limit each IP address to 100 requests per minute. When the limit is exceeded, Rails returns a 429 Too Many Requests response automatically.
The by block is crucial—it determines the rate limit key. You can rate limit by IP, by user ID, by API key, or any combination:
class ApiController < ApplicationController
# Rate limit by authenticated user
rate_limit to: 1000, within: 1.hour, by: -> { current_user&.id }
# Rate limit by API key from request headers
rate_limit to: 500, within: 1.hour, by: -> { request.headers['X-API-Key'] }
# Stricter limit for unauthenticated requests
rate_limit to: 10, within: 1.minute, by: -> { request.remote_ip }, if: -> { current_user.nil? }
def index
render json: Resource.all
end
endYou can also apply multiple rate limits to the same action, stacking them for layered protection. The trade-off is simplicity—Rails's built-in approach works well for standard cases but lacks the flexibility of Rack::Attack for complex rules.
Rack::Attack for advanced rules
When you need more sophisticated rate limiting—throttling based on request path patterns, custom logic, or combining multiple conditions—Rack::Attack is the go-to gem. It sits at the Rack middleware level, so it catches requests before they hit your controller.
First, add it to your Gemfile and configure it:
# Gemfile
gem 'rack-attack'Create an initializer at config/initializers/rack_attack.rb:
class Rack::Attack
# Store your rate limit counters in Redis for distributed systems
cache.store = ActiveSupport::Cache.lookup_store(:redis_cache_store)
# Throttle requests by IP for the entire API
throttle('req/ip', limit: 300, period: 1.minute) do |req|
req.remote_ip if req.path.start_with?('/api')
end
# Stricter limit for login attempts
throttle('logins/ip', limit: 5, period: 1.hour) do |req|
req.remote_ip if req.path == '/users/sign_in' && req.post?
end
# Rate limit by username to prevent user enumeration
throttle('logins/username', limit: 5, period: 1.hour) do |req|
req.params['email'] if req.path == '/users/sign_in' && req.post?
end
endRack::Attack evaluates each throttle rule and returns the limit key (the last value in the block). If that key exceeds its limit, Rack::Attack blocks the request before Rails sees it.
You can also use custom logic to track more sophisticated patterns:
class Rack::Attack
# Throttle by multiple factors: IP + User-Agent
throttle('api/device', limit: 100, period: 5.minutes) do |req|
if req.path.start_with?('/api/v1')
"#{req.remote_ip}:#{req.user_agent}"
end
end
# Safelist internal IPs
safelist('allow internal') do |req|
['127.0.0.1', '::1'].include?(req.remote_ip) ||
req.remote_ip.start_with?('10.0.0')
end
endProtecting login and signup endpoints
Login and signup endpoints are prime targets. Attackers want to brute-force credentials or flood your system with fake accounts. These endpoints deserve tighter limits than your general API.
Here's a multi-layered approach using Rack::Attack:
class Rack::Attack
# Limit login attempts per IP
throttle('login/ip', limit: 5, period: 15.minutes) do |req|
req.remote_ip if req.path == '/users/sign_in' && req.post?
end
# Limit login attempts per email (prevent user enumeration)
throttle('login/email', limit: 5, period: 15.minutes) do |req|
if req.path == '/users/sign_in' && req.post?
req.params['email']
end
end
# Limit signup by IP to prevent account farming
throttle('signup/ip', limit: 3, period: 1.hour) do |req|
req.remote_ip if req.path == '/users' && req.post?
end
# Limit signup by email to prevent duplicate registrations
throttle('signup/email', limit: 1, period: 24.hours) do |req|
if req.path == '/users' && req.post?
req.params['email']
end
end
endCombine this with your controller logic:
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
sign_in @user
render json: @user, status: :created
else
render json: { errors: @user.errors.full_messages }, status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:email, :password)
end
endThe Rack::Attack throttles happen silently in the middleware layer, returning a 429 response before your controller code runs. This saves resources and provides consistent protection across all endpoints.
Understanding 429 responses
When a client hits a rate limit, both Rails's built-in rate_limit and Rack::Attack return HTTP 429 Too Many Requests by default. This is the standardized response code, so clients—including mobile apps, web frontends, and API consumers—know to back off and retry later.
You can customize the 429 response:
# config/initializers/rack_attack.rb
class Rack::Attack
self.throttled_response = lambda do |env|
retry_after = env['rack.attack.match_data'][:period]
headers = { 'Retry-After' => retry_after.to_s }
[
429,
headers,
[JSON.generate({ error: 'Too many requests. Please retry later.' })]
]
end
endThe Retry-After header tells clients how long to wait before trying again. This follows HTTP standards and helps client libraries implement exponential backoff automatically.
For user-facing pages, you might return HTML:
self.throttled_response = lambda do |env|
[
429,
{ 'Content-Type' => 'text/html' },
["<h1>Rate Limited</h1><p>Please try again in a few moments.</p>"]
]
endCommon mistakes
Forgetting to configure a distributed cache: If you run multiple Rails processes or servers, rate limit counters must live in Redis or Memcached, not in-memory. Otherwise, each process counts independently and limits become ineffective. Always configure your cache store in the Rack::Attack initializer.
Rate limiting by IP only: Behind a load balancer or proxy, all traffic appears to come from the same IP. Use request.remote_ip carefully and consider extracting the real IP via the X-Forwarded-For header. Rails's request.remote_ip already handles this, but verify your load balancer is sending the header.
Making limits too strict: A limit of 1 request per second might feel safe, but legitimate clients with slow networks or mobile connections will hit it. Test your limits with real traffic patterns and adjust based on monitoring.
Not handling rate limits gracefully in the UI: When users hit a rate limit, show them a clear message and the estimated retry time. A cryptic 429 error frustrates users. Use the Retry-After header in your frontend to show a countdown timer.
Combining conflicting rules: If you have overlapping Rack::Attack throttles—one for IP and one for user ID—ensure they make sense together. A user on a shared network might hit both limits, creating a poor experience.
Key takeaways
- Use Rails 8's built-in
rate_limitmethod for simple, single-endpoint rules. It requires no extra gems and integrates cleanly with your controller. - Reach for Rack::Attack when you need pattern matching, multiple conditions, or middleware-level protection. It's battle-tested across thousands of production Rails apps.
- Always store rate limit counters in Redis or Memcached for distributed systems, not in-memory stores.
- Protect login and signup endpoints with stricter limits and multiple throttle rules (by IP, by email) to prevent brute force and account farming.
- Return standardized HTTP 429 responses with a
Retry-Afterheader so clients can implement backoff logic correctly. - Test your limits with realistic traffic patterns and monitor false positives from legitimate users, especially those on shared networks.






Comments
Loading comments…