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 Limiting in Rails met Rack::Attack: Een Productie-configuratiegids

Rate Limiting in Rails met Rack::Attack: Een Productie-configuratiegids

roger
Configureer Rack::Attack in Rails 8 om misbruik te throttlen, kwaadwillenden te blokkeren en je API-endpoints te beschermen. Met productie-geteste configuraties, Redis vs memory store afwegingen en monitoring-strategieën.

Rack::Attack is middleware die tussen je Rails-app en inkomende requests zit. Het throttlet, blokkeert en monitort requests voordat ze je controllers bereiken. Als je een Rails-app in productie draait zonder rate limiting, ben je één bot verwijderd van een slechte dag.

Hier lees je hoe je het correct instelt in Rails 8, inclusief de configuratie-valkuilen die ik in productie ben tegengekomen.

Installatie en Basisopzet

Voeg de gem toe aan je Gemfile:

gem "rack-attack", "~> 6.7"

Maak de initializer aan op config/initializers/rack_attack.rb:

class Rack::Attack
  # Gebruik Rails cache als backing store
  Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new

  # Throttle alle requests per IP (300 requests per 5 minuten)
  throttle("req/ip", limit: 300, period: 5.minutes) do |req|
    req.ip unless req.path.start_with?("/assets")
  end
end

Dat is voldoende voor een minimale opzet. Maar een minimale opzet redt je niet wanneer een scraper besluit dat jouw API zijn persoonlijke datawarehouse is.

Throttle-regels die Echt Werken

De truc met rate limiting is specifiek zijn. Eén globale throttle is te bot — die irriteert legitieme gebruikers voordat het aanvallers stopt. Werk met lagen:

class Rack::Attack
  ### Throttle inlogpogingen ###
  throttle("logins/ip", limit: 5, period: 20.seconds) do |req|
    req.ip if req.path == "/session" && req.post?
  end

  # Throttle inlogpogingen per e-mailadres (voorkomt 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 wachtwoord-resets ###
  throttle("password_resets/ip", limit: 3, period: 15.minutes) do |req|
    req.ip if req.path == "/password_resets" && req.post?
  end

  ### API-endpoints — strakkere limieten ###
  throttle("api/ip", limit: 60, period: 1.minute) do |req|
    req.ip if req.path.start_with?("/api/")
  end

  ### Algemene request-throttle ###
  throttle("req/ip", limit: 300, period: 5.minutes) do |req|
    req.ip unless req.path.start_with?("/assets")
  end
end

Een paar opmerkingen bij deze configuratie. De login-throttle gebruikt twee discriminators: IP-adres en e-mail. Dit vangt zowel brute-force aanvallen vanaf één IP als gedistribueerde credential stuffing tegen één account. De API-throttle is gescheiden van de algemene throttle omdat API-endpoints doorgaans duurder zijn om te serveren — ze raken de database, serialiseren JSON en roepen soms externe services aan.

Een Cache Store Kiezen

De cache.store instelling bepaalt waar Rack::Attack request-tellingen bijhoudt. Deze keuze is belangrijker dan de meeste handleidingen suggereren.

MemoryStore werkt voor single-server deployments:

Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new

De teller reset bij een process-herstart, en elke Puma-worker heeft zijn eigen teller. Als je 4 Puma-workers draait, krijgt een aanvaller effectief 4× je geconfigureerde limiet. Voor kleine apps achter één server is dit meestal prima.

Redis is wat je wilt voor multi-server deployments of wanneer nauwkeurigheid belangrijk is:

Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
  url: ENV["REDIS_URL"],
  expires_in: 10.minutes
)

Met Solid Cache dat nu beschikbaar is in Rails 8, vraag je je misschien af of je dat hier kunt gebruiken. Dat kan — Rack::Attack heeft alleen een ActiveSupport::Cache::Store-compatible backend nodig. Maar er zit een addertje onder het gras: Solid Cache schrijft naar je database, en rate limiting genereert veel writes. Elk request triggert een cache-increment. Onder zware belasting (wat precies het moment is waarop rate limiting er het meest toe doet) voeg je aanzienlijke write-load toe aan je database op het slechtst mogelijke moment.

Blijf bij Redis of MemoryStore voor rate limiting. Gebruik Solid Cache voor caching op applicatieniveau waar write-patronen minder intensief zijn.

Kwaadwillenden Blokkeren

Throttling vertraagt aanvallers. Blokkering stopt ze volledig:

class Rack::Attack
  # Blokkeer requests van bekende slechte IP's
  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

  # Blokkeer requests met verdachte user agents
  blocklist("block scrapers") do |req|
    bad_agents = %w[
      AhrefsBot
      SemrushBot
      DotBot
      MJ12bot
    ]
    bad_agents.any? { |agent| req.user_agent&.include?(agent) }
  end

  # Sta altijd requests toe van je monitoring-service
  safelist("allow monitoring") do |req|
    req.user_agent&.include?("UptimeRobot")
  end
end

Safelists worden geëvalueerd vóór blocklists en throttles. Als je monitoring-service frequente requests maakt, safelist deze dan zodat het je throttle niet triggert en valse meldingen genereert over downtime.

Aangepaste Responses

De standaard 429-response is een kale Retry-After header en een lege body. Voor API’s wil je iets informatiever:

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

Ik heb apps gezien die een 200 retourneren met een foutmelding in de body bij rate limiting. Doe dat niet. HTTP-clients en CDN’s begrijpen 429 en zullen automatisch terugschakelen. Een 200 vertelt ze dat alles prima is en ze door moeten gaan.

Monitoring en Alerting

Rack::Attack publiceert ActiveSupport-notificaties. Abonneer je erop:

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

Als je OpenTelemetry draait, stuur deze dan als custom spans of metrics. Een plotselinge piek in throttle-events vertelt je dat er iets mis is voordat je foutpercentage dat doet.

Volg deze metrics in productie:

  • Throttle-events per minuut per regelnaam
  • Unieke IP’s die gethrottled worden
  • Block-events per minuut
  • Verhouding gethrottlede tot totaal aantal requests

Wanneer gethrottlede requests meer dan 5% van het totale verkeer overschrijden, wordt je app actief aangevallen of zijn je limieten te agressief.

Je Configuratie Testen

Rack::Attack biedt een test-helper, maar ik vind het makkelijker om te testen met integratietests die daadwerkelijk de middleware raken:

# 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 "throttlet buitensporige inlogpogingen" do
    6.times do
      post "/session", params: { session: { email: "test@example.com", password: "wrong" } }
    end

    assert_equal 429, response.status
  end

  test "staat requests onder de limiet toe" do
    3.times do
      post "/session", params: { session: { email: "test@example.com", password: "wrong" } }
    end

    assert_not_equal 429, response.status
  end
end

Roep Rack::Attack.reset! aan in je test-setup. Zonder dit lekken request-tellingen tussen tests en krijg je flaky failures die alleen optreden wanneer tests in een specifieke volgorde draaien.

Omgaan met Proxies en Load Balancers

Als je Rails-app achter Nginx, Cloudflare of een load balancer zit, retourneert req.ip het IP-adres van de proxy — niet dat van de client. Elke gebruiker deelt één IP, en je throttle wordt nutteloos.

Los dit op met ActionDispatch::RemoteIp middleware (die Rails standaard bevat) en zorg dat je proxy de juiste headers doorstuurt:

# Nginx-configuratie
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

Rails gebruikt X-Forwarded-For om het echte client-IP te bepalen. Als je achter Cloudflare zit, vertrouw dan ook de CF-Connecting-IP header door Cloudflare’s IP-ranges toe te voegen aan config.action_dispatch.trusted_proxies.

Exponentiële Backoff voor Herhaaldelijke Overtreders

De standaard throttle reset na afloop van de periode. Een vastberaden aanvaller wacht gewoon en probeert opnieuw. Voor login-endpoints werkt exponentiële backoff beter:

# Oplopende throttle: 5 pogingen, dan 25, dan 50 per periode
(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

Dit geeft gewone gebruikers met typefouten ruimte om opnieuw te proberen terwijl aanhoudende aanvallen steeds pijnlijker worden.

Veelgemaakte Fouten

Health checks niet uitsluiten. Als je load balancer elke 5 seconden /health pingt over 10 instances, zijn dat 120 requests per minuut vanaf hetzelfde IP. Safelist het.

Throttlen op sessie in plaats van IP. Niet-geauthenticeerde aanvallers hebben geen sessies. Gebruik altijd een IP-gebaseerde throttle als basis.

Limieten te laag instellen bij lancering. Wanneer je app Hacker News haalt, kan legitiem verkeer eruitzien als een aanval. Begin met ruime limieten en verscherp op basis van werkelijke verkeerspatronen.

Achtergrondtaak-endpoints vergeten. Als je app webhook-URL’s blootstelt die achtergrondtaken triggeren, throttle die dan apart. Een aanvaller die je webhook-endpoint bestookt kan je job-queue vullen en echt werk verdringen.

FAQ

Hoe verschilt Rack::Attack van Rails 8’s ingebouwde rate limiting?

Rails 8 heeft rate_limit toegevoegd op controller-niveau, wat handig is voor eenvoudige gevallen. Rack::Attack werkt als Rack-middleware, dus het wijst requests af voordat ze je router, controllers of database bereiken. Voor API-bescherming en brute-force preventie is Rack::Attack efficiënter omdat afgewezen requests bijna geen applicatie-resources verbruiken. Gebruik Rails 8’s ingebouwde rate_limit voor business-logica limieten (zoals “5 exports per uur”) en Rack::Attack voor infrastructuurbescherming.

Moet ik Rack::Attack gebruiken achter Cloudflare’s rate limiting?

Ja, behandel ze als verschillende lagen. Cloudflare vangt volumetrische DDoS en bot-verkeer op aan de edge. Rack::Attack behandelt applicatie-specifieke regels zoals login-throttling en API rate limits die begrip van je URL-structuur vereisen. Cloudflare weet niet dat /session je login-endpoint is of dat /api/v1/search duur is om te serveren.

Hoe rate limit ik geauthenticeerde API-gebruikers anders?

Gebruik de API-key of user-ID als discriminator in plaats van (of naast) het 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

Dit geeft elke API-consument zijn eigen limiet-bucket. Combineer het met een IP-gebaseerde throttle om niet-geauthenticeerd misbruik op te vangen.

Wat is de performance-overhead van Rack::Attack?

Verwaarloosbaar voor MemoryStore — het is een in-memory hash lookup per request. Met Redis voeg je één netwerk-round-trip toe per request per matchende regel. In de praktijk heb ik minder dan 1ms overhead gemeten met Redis op hetzelfde netwerk. De overhead van géén rate limiting (dure requests serveren aan aanvallers) is ordes van grootte hoger.

Hoe schakel ik rate limiting tijdelijk uit tijdens load testing?

Stel een omgevingsvariabele in en controleer die in je 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

Deploy nooit zonder rate limiting. Ik heb teams gezien die vergaten het weer in te schakelen na een load test en het gat pas ontdekten na een incident.

#rails #security #devops #performance
r

About the Author

Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.

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