35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rate Limit Your Rails API With Rack::Attack (Step-by-Step)

Rate Limit Your Rails API With Rack::Attack (Step-by-Step)

roger
How to add rate limiting to a Rails 8 API using Rack::Attack. Covers throttling, blocklists, custom responses, and production configuration with Redis and Solid Cache.

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.

#ruby-on-rails #api #security #performance
r

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 Touch

Share this article

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