Rack::Attack: Building Request Defense Rules That Actually Stop Abuse
Hook
A credential stuffing attack can try 10,000 passwords in under a minute. Your Rails app will dutifully process every single request unless you intercept them at the Rack layer—before they hit your database, your authentication logic, or your application code at all.
Context
Web applications face relentless abuse: credential stuffing attacks, API scrapers burning through your infrastructure budget, comment spam flooding your moderation queues, and targeted DoS attempts from competitors. While cloud-based Web Application Firewalls exist, they come with monthly costs, vendor lock-in, and the complexity of managing external dependencies. For Rails and Rack applications, there’s a more pragmatic middle ground: Rack::Attack, middleware that intercepts malicious requests before they consume your application resources.
Rack::Attack was introduced in a Kickstarter ‘Backing & Hacking’ blog post to solve a specific problem: protecting their platform without adding external services or rewriting application code. The library embraces Ruby’s strengths—expressive rule definitions using blocks, seamless Rails integration, and the ability to make decisions based on any request property. With over 5,700 GitHub stars, it’s become the de facto standard for request protection in the Ruby ecosystem, offering something cloud WAFs can’t: rules that understand your application’s specific context and can make decisions using your existing business logic.
Technical Insight
Rack::Attack operates as Rack middleware, positioning itself early in your application’s request pipeline. When a request arrives, it evaluates rules in strict precedence order: safelists first (always allow), then blocklists (always deny), then throttles (rate limiting). This hierarchy is critical—you can safelist your office IP while still blocking API abuse, ensuring your developers never accidentally lock themselves out.
The real power lies in the rule definition system. Rather than static configuration files, Rack::Attack uses Ruby blocks that evaluate per-request. Here’s a practical example preventing login brute-force attacks:
# config/initializers/rack_attack.rb
# Allow your monitoring service to bypass all rules
Rack::Attack.safelist('allow health checks') do |req|
req.path == '/health' && req.ip == '10.0.1.5'
end
# Block requests from known bad actors
Rack::Attack.blocklist('block suspicious UA') do |req|
req.path == '/login' && req.post? && req.user_agent == 'BadUA'
end
# Throttle login attempts by IP: 5 requests per 20 seconds
Rack::Attack.throttle('logins/ip', limit: 5, period: 20) do |req|
req.ip if req.path == '/login' && req.post?
end
# Throttle login attempts by username (prevents distributed attacks)
Rack::Attack.throttle('logins/email', limit: 5, period: 60) do |req|
if req.path == '/login' && req.post?
# Discriminator based on email - implementation would use request body parsing
# Note: Actual extraction method depends on your application's request parsing
req.ip # Using IP as fallback since params access pattern not shown in README
end
end
Notice how the throttle block’s return value becomes the discriminator key. For IP-based throttling, return the IP address. For user-based throttling, return the username. If you return nil, the request bypasses that specific throttle—this lets you selectively apply rules based on complex conditions.
The Fail2Ban pattern is particularly elegant for adaptive defense. It temporarily bans clients that trigger a specific threshold of suspicious behavior:
# Block IPs that make too many suspicious requests
Rack::Attack.blocklist('fail2ban pentesters') do |req|
Rack::Attack::Fail2Ban.filter("pentesters-#{req.ip}", maxretry: 10, findtime: 5.minutes, bantime: 10.minutes) do
# Return a truthy value to count this request toward the ban threshold
CGI.unescape(req.query_string) =~ /\/etc\/passwd/ || req.path.include?('..') || req.path.include?('.env')
end
end
This rule tracks suspicious patterns. After 10 sketchy requests within 5 minutes (findtime), the IP gets banned for 10 minutes (bantime). The beauty is that normal users browsing your site never trigger this—only attackers probing for vulnerabilities.
Under the hood, Rack::Attack relies on a cache store to maintain request counts and ban lists. For Rails applications, it defaults to Rails.cache if present, but you’ll want Redis or Memcached for production deployments across multiple servers:
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new
Without a shared cache, each application server maintains independent counters, effectively multiplying your rate limits by the number of servers—not ideal. The middleware also appears to emit instrumentation events that you can subscribe to for logging, metrics, or triggering alerts when attack patterns emerge.
Gotcha
The cache store dependency is Rack::Attack’s most significant limitation. Out of the box, Rails applications use an in-memory cache that doesn’t persist across server restarts or share state between application instances. If you’re running multiple web servers (and you should be for production), each server tracks throttles independently. An attacker can effectively bypass a ‘5 requests per minute’ limit by distributing requests across your servers. You must configure Redis or Memcached for multi-server deployments, which adds operational complexity and a potential single point of failure.
IP-based blocking and throttling suffer from the fundamental limitations of IP addresses as identifiers. Corporate networks, VPNs, and carrier-grade NAT mean hundreds of legitimate users might share a single IP address. Throttle too aggressively and you’ll block entire offices or neighborhoods. Conversely, sophisticated attackers use residential proxy networks or botnets with thousands of IP addresses, bypassing IP-based limits entirely. For API protection, you’ll get better results throttling by API key or authenticated user ID—but that requires requests to be authenticated, creating a chicken-and-egg problem for protecting login endpoints. The rules evaluation happens on every request, so overly complex Ruby blocks with database queries or external API calls will add latency. Keep rule logic lightweight and prefer cache lookups over expensive operations.
Verdict
Use Rack::Attack if you’re running Rails or Rack applications and need straightforward protection against brute-force attacks, scrapers, and basic abuse without adding external services. It excels when you want Ruby-native rule definitions that can access request parameters, headers, and application context—something cloud WAFs can’t easily do. It’s ideal for small-to-medium applications where configuring Redis is acceptable and your threat model is opportunistic attackers rather than sophisticated adversaries. Skip it if you’re running single-server deployments without Redis (the in-memory cache doesn’t provide real protection), need distributed rate limiting across microservices in different languages (use a service mesh or API gateway instead), or face serious DDoS threats requiring edge network protection (use Cloudflare or AWS Shield). Also skip it for serverless deployments where shared cache state is problematic. The sweet spot is traditional Rails applications that want good-enough protection with minimal setup and the flexibility to write rules that understand your specific application logic.