Rails Rack::Attack: Rate Limiting, IP-Throttling en Misbruik Blokkeren in Productie
Rails Rack::Attack gids: rate limiting, IP-throttling, fail2ban en misbruik blokkeren in productie. Werkende config voor Rails 8 APIs en login endpoints.
Een klant belde me afgelopen november op een zondag omdat hun signup pagina was veranderd in een spam-automaat. Iemand had een residentieel proxy-botnet losgelaten op POST /users en maakte 400 accounts per minuut aan, elk account triggerde een welkomstmail, en elke welkomstmail duwde hun SendGrid-reputatie een stukje verder de afgrond in. Hun Puma workers hadden het prima; hun Postgres had het prima; hun reputatie bij elke mailbox provider in Noord-Amerika had het niet prima. Twee uur later was de vloed gestopt, en de fix bestond uit negentig regels Rails Rack::Attack config voor hun bestaande app.
Na negentien jaar Rails heb ik Rails Rack::Attack op vrijwel elke productie-app die ik heb opgeleverd geïnstalleerd, en ik heb gezien hoe de apps zonder het uiteindelijk betaalden voor die omissie — meestal om drie uur ‘s nachts, meestal direct na een Product Hunt lancering. Deze post is de exacte Rails Rack::Attack setup die ik gebruik: throttles die daadwerkelijk matchen met hoe aanvallers zich gedragen, fail2ban regels die repeat offenders afsnijden, safelists die je monitoring groen houden, en de Redis-backed store die de rondes tussen je Puma workers overleeft.
Waarom Rails Rack::Attack in Elke Productie-App Thuishoort
Rails Rack::Attack is een Rack middleware geschreven door Aaron Suggs bij Kickstarter die voor je Rails-app zit en elke inkomende request inspecteert. Het geeft je vier werkwoorden — throttle, blocklist, safelist, track — en een gedeelde counter store, en dat is genoeg om het overgrote deel van application-layer misbruik op te lossen: credential stuffing, signup spam, scraping, forgot-password floods, en de klassieke “één klant met een verkeerd geconfigureerde cron slaat elke seconde twaalf keer op /api/v1/orders voor altijd.”
De alternatieven zijn op specifieke manieren slechter:
- Nginx
limit_reqwerkt, maar ziet alleen IP’s. Het kan niet rate-limiten op email, API key of ingelogde gebruiker, en kan je session cookie niet lezen om onderscheid te maken tussen “de gebruiker die daadwerkelijk is ingelogd” en “de bot die doet alsof.” Elke echte limiet die je wil schrijven heeft uiteindelijk applicatiecontext nodig. - Cloudflare rate limiting is uitstekend aan de edge maar duur per regel op het Pro plan en onzichtbaar voor je Rails-logs en metrics. Gebruik het als eerste linie en Rails Rack::Attack als tweede — ze zijn complementair, geen substituten.
- Niets doen en hopen is wat de meeste Rails-apps doen tot het eerste incident. Wees niet die app.
De gem is klein, saai, en is meer dan tien jaar production-hardened op Kickstarter, GitHub en Basecamp schaal. Installeer het één keer, zet vijf regels in een config file, en je hebt een hele klasse zondagochtend-telefoontjes geëlimineerd.
Rack::Attack Installeren en de Cache Store Aansluiten
Voeg de gem toe en genereer de initializer:
# Gemfile
gem "rack-attack", "~> 6.7"
gem "redis", "~> 5.0" # als je er nog niet op zit
# config/application.rb
config.middleware.use Rack::Attack
De belangrijkste beslissing is de cache store. Rails Rack::Attack telt requests, en die tellingen moeten worden gedeeld tussen elke Puma worker op elke server. De default Rails.cache is in productie vaak :memory_store bij developers die het nooit hebben geconfigureerd, wat betekent dat elk Puma proces zijn eigen requests telt en een regel van “5 per seconde” wordt “5 per seconde per worker maal zestien workers maal drie servers” — effectief geen limiet.
Gebruik Redis expliciet, en gebruik een dedicated Redis of database nummer zodat je throttle counters je fragment cache niet evicten:
# config/initializers/rack_attack.rb
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
url: ENV.fetch("RACK_ATTACK_REDIS_URL", ENV.fetch("REDIS_URL")),
namespace: "rack-attack",
expires_in: 1.hour,
error_handler: ->(method:, returning:, exception:) {
Sentry.capture_exception(exception, level: :warning)
returning
}
)
De error handler is belangrijker dan hij eruitziet. Als Redis onderuit gaat en Rails Rack::Attack een exception raist, gaat elke request in je app 500’en. De block hierboven retourneert de sentinel-waarde (meestal nil of 0), waardoor throttling degradeert naar “sta de request toe” in plaats van “haal de hele site onderuit.” Fail-open op een rate limiter is de juiste keuze; fail-closed is hoe je van een Redis-hikje een complete outage maakt.
Login Attempts Throttlen om Credential Stuffing Te Killen
De enige hoogst-waardevolle Rails Rack::Attack regel voor bijna elke app is het throttlen van POST /users/sign_in (of wat jouw login pad ook is) op zowel IP als target email. Credential stuffing aanvallen — waarbij een aanvaller gelekte username/password paren van een andere breach opnieuw afspeelt — zijn de dagelijkse realiteit van het runnen van een authenticated app in 2026, en een goed geplaatste throttle stopt ze voordat de aanvaller überhaupt leert welke accounts bestaan op je systeem.
# config/initializers/rack_attack.rb
# Throttle login attempts per IP: 5 per 20 seconden.
Rack::Attack.throttle("logins/ip", limit: 5, period: 20.seconds) do |req|
req.ip if req.path == "/users/sign_in" && req.post?
end
# Throttle login attempts per target email: 5 per 60 seconden.
# Dit stopt gedistribueerde aanvallen die IP's rouleren maar één account hameren.
Rack::Attack.throttle("logins/email", limit: 5, period: 60.seconds) do |req|
if req.path == "/users/sign_in" && req.post?
email = req.params.dig("user", "email").to_s.downcase.strip.presence
email
end
end
De twee-dimensionale throttle is wat dit daadwerkelijk laat werken. IP-only limieten sneuvelen tegen elke aanvaller met 500 residentiële proxies. Email-only limieten zijn triviaal te omzeilen door targets te rouleren. Samen dwingen ze de aanvaller in een hoek: om volume vol te houden hebben ze veel IP’s nodig en om een specifiek account te compromitteren hebben ze veel pogingen tegen die email nodig — en één van de throttles gaat sowieso af.
Twee subtiliteiten die mensen bijten. Ten eerste, throttle niet op IP als je app achter een proxy zit zonder dat ActionDispatch::RemoteIp correct is geconfigureerd — req.ip is dan het IP van de load balancer en één throttle legt je hele userbase stil. Verifieer met curl vanaf een extern netwerk en lees de waarde die je daadwerkelijk krijgt. Ten tweede, doe email.downcase.strip zelf; vertrouw niet op de frontend om te normaliseren voor het Rack raakt, omdat de middleware voor je controller draait.
API Throttling per API Key of Authenticated User
Voor JSON APIs is de juiste throttle-discriminator bijna nooit het IP — het is de API key of het ID van de authenticated user. Elke echte API client deelt uiteindelijk een uitgaand IP met honderden andere tenants (AWS NAT, corporate proxies, Vercel edge functions), en throttlen op IP zal de nette klant straffen die toevallig een /24 deelt met een luidruchtige.
# Throttle authenticated API requests op API key.
# 300 requests per minuut is ongeveer 5 rps volgehouden — royaal voor de meeste REST clients.
Rack::Attack.throttle("api/key", limit: 300, period: 1.minute) do |req|
if req.path.start_with?("/api/")
req.env["HTTP_AUTHORIZATION"]&.sub(/^Bearer /, "")&.presence
end
end
# Goedkopere throttle voor dure endpoints — die de LLM raken,
# full-text search doen, of rapporten draaien. Tune per endpoint.
Rack::Attack.throttle("api/expensive", limit: 30, period: 1.minute) do |req|
if req.path.start_with?("/api/") && EXPENSIVE_PATHS.match?(req.path)
req.env["HTTP_AUTHORIZATION"]&.sub(/^Bearer /, "")&.presence
end
end
EXPENSIVE_PATHS = %r{\A/api/v1/(search|reports|ai/)}
Het patroon dat ik het meest gebruik is een twee-lagen throttle: een royale “alles” limiet die runaway loops stopt, en een veel strakkere limiet op de specifieke endpoints waar één request je echt geld kost (LLM calls, PDF generatie, Postgres queries boven de 100 ms). Een klant die per ongeluk oneindig loopt tegen /api/v1/search moet bij request 30 de strakke limiet raken, niet bij request 300 nadat ze hun maandelijkse OpenAI budget al hebben opgeblazen.
Als je gepubliceerde API plans gebruikt (Free/Pro/Enterprise), maak de limiet dynamisch:
Rack::Attack.throttle("api/key", limit: ->(req) { req.env["rack.attack.limit"] || 60 }, period: 1.minute) do |req|
next unless req.path.start_with?("/api/")
key = req.env["HTTP_AUTHORIZATION"]&.sub(/^Bearer /, "")&.presence
next unless key
# Cache de limit-lookup 5 minuten zodat we Postgres niet op elke request raken.
limit = Rails.cache.fetch("api-limit/#{key}", expires_in: 5.minutes) do
ApiKey.find_by(token: key)&.plan_rate_limit || 60
end
req.env["rack.attack.limit"] = limit
key
end
De cache lookup is essentieel — een naïeve implementatie die je ApiKey tabel op elke request raakt, maakt Rails Rack::Attack een groter performance-probleem dan het misbruik dat het moet stoppen.
Signup Throttling en Forgot-Password Floods
Het verhaal waarmee ik deze post begon was signup spam, en de regel die het in drie regels heeft opgelost is het waard om uit te schrijven:
# 3 signups per IP per uur — sneller is vrijwel zeker automatisering.
Rack::Attack.throttle("signups/ip", limit: 3, period: 1.hour) do |req|
req.ip if req.path == "/users" && req.post?
end
# 20 signups per IP per dag — vangt de "slow drip" botnet variant.
Rack::Attack.throttle("signups/ip/day", limit: 20, period: 1.day) do |req|
req.ip if req.path == "/users" && req.post?
end
# Forgot-password op email — anders ben je een inbox flooder voor iedereen
# met een lijst van de emails van jouw gebruikers.
Rack::Attack.throttle("forgot/email", limit: 3, period: 1.hour) do |req|
if req.path == "/users/password" && req.post?
req.params.dig("user", "email").to_s.downcase.strip.presence
end
end
De dubbele-periode signup throttle vangt twee verschillende aanvalvormen met dezelfde discriminator, wat een patroon is om te internaliseren. Aanvallers die tegen de “3 per uur” limiet aanlopen en terugtrekken worden alsnog gepakt door de “20 per dag” cap; aanvallers die langzaam gaan om de day-limit te ontwijken worden alsnog gepakt op het moment dat ze proberen te sprinten. Wanneer je één throttle schrijft, schrijf ook de langzamere companion.
Fail2ban: Automatische Blokkade van Repeat Offenders
De vervelendere variant van elke aanval is degene waarbij de aanvaller voorzichtig aftast, jouw throttle-limieten leert door trial, en dan voor altijd op precies 4 requests per 20 seconden meerijdt. Rails Rack::Attack heeft specifiek een blocklist werkwoord hiervoor — een regel die naar een patroon kijkt en, eenmaal gezien, de bron blokkeert voor een veel langer window.
# Blokkeer iedereen die de login throttle meer dan 3 keer in 10 minuten trigger.
Rack::Attack.blocklist("fail2ban/logins") do |req|
Rack::Attack::Fail2Ban.filter("fail2ban-login-#{req.ip}", maxretry: 3, findtime: 10.minutes, bantime: 1.hour) do
req.path == "/users/sign_in" && req.post? &&
req.env["rack.attack.matched"] == "logins/ip"
end
end
# Blokkeer iedereen die naar bekende WordPress/PHP paden probeert — niemand legitiems
# raakt /wp-login.php of /.env op een Rails-app.
BAD_PATHS = %r{\A/(wp-login\.php|wp-admin|\.env|\.git|phpinfo\.php|xmlrpc\.php|admin\.php)}
Rack::Attack.blocklist("fail2ban/scanners") do |req|
Rack::Attack::Fail2Ban.filter("fail2ban-scan-#{req.ip}", maxretry: 1, findtime: 1.minute, bantime: 24.hours) do
BAD_PATHS.match?(req.path)
end
end
De scanner-ban is één van mijn favoriete regels omdat hij in wezen gratis is. Een Rails-app heeft geen legitieme reden om /wp-login.php te serveren, en elk IP dat er om vraagt runt een mass scanner die vervolgens /admin, /console en uiteindelijk een directory-traversal payload tegen iets echts gaat proberen. Ze na één probe voor 24 uur bannen verwijdert een stuk van de ruisvloer uit je logs en haalt de druk van de subtielere throttles.
Safelisten: Ban Je Eigen Uptime Monitor Niet
Elke Rails Rack::Attack deployment die ik ooit heb opgezet had binnen de eerste week een safelist nodig, meestal omdat Pingdom of UptimeRobot een regel triggerde. Doe de safelist vóór de throttles, en wees specifiek:
# Throttle nooit health checks — anders haalt een slechte throttle-regel
# de hele health-perceptie van je load balancer onderuit.
Rack::Attack.safelist("allow/health") do |req|
req.path == "/up" || req.path == "/health"
end
# Safelist je kantoor- en CI-IP's. Zet ze in een ENV var, niet in de source,
# zodat ops ze zonder deploy kan roteren.
OFFICE_IPS = ENV.fetch("RACK_ATTACK_SAFELIST_IPS", "").split(",").map(&:strip)
Rack::Attack.safelist("allow/office") do |req|
OFFICE_IPS.include?(req.ip)
end
Safelist geen “ingelogde gebruikers” — dat is exact de populatie die het meest kwetsbaar is voor session hijacking, en ze safelisten verwijdert je bescherming op het moment dat je het meest nodig hebt. Safelist wél de paden waarvan het falen je alerting stack in de war schopt.
Nuttige Responses Teruggeven, Niet Alleen 429
De default Rails Rack::Attack response is een plain-text 429 Too Many Requests body, wat prima is voor mensen maar nutteloos voor API clients die willen weten wanneer ze moeten retryen. Customize de response:
Rack::Attack.throttled_responder = lambda do |req|
match_data = req.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,
"RateLimit-Limit" => match_data[:limit].to_s,
"RateLimit-Remaining" => "0",
"RateLimit-Reset" => (now + retry_after).to_s
}
body = {
error: "rate_limited",
message: "Retry in #{retry_after} seconds",
retry_after: retry_after
}.to_json
[429, headers, [body]]
end
Rack::Attack.blocklisted_responder = lambda do |_req|
[403, { "Content-Type" => "application/json" }, [{ error: "forbidden" }.to_json]]
end
De RateLimit-* headers volgen de IETF draft standaard, en moderne HTTP client libraries in elke grote taal weten hoe ze ze moeten lezen en intelligent moeten backoffen. Je eigen SDKs krijgen dit gedrag gratis.
Rack::Attack Regels Testen
Ongeteste throttles zijn erger dan geen throttles — een gebroken regel sluit ofwel je echte gebruikers buiten ofwel laat stilletjes de aanvallen door die het moest tegenhouden. RSpec tests horen thuis in elke Rails Rack::Attack config:
# spec/rack_attack_spec.rb
require "rails_helper"
RSpec.describe "Rack::Attack", type: :request do
before do
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
Rack::Attack.reset!
end
describe "login throttling" do
let(:credentials) { { user: { email: "victim@example.com", password: "wrong" } } }
it "throttlet na 5 pogingen vanaf hetzelfde IP" do
5.times do
post "/users/sign_in", params: credentials
expect(response.status).not_to eq(429)
end
post "/users/sign_in", params: credentials
expect(response.status).to eq(429)
expect(response.headers["Retry-After"]).to be_present
end
it "throttlet op email zelfs als het IP rouleert" do
5.times do |i|
post "/users/sign_in",
params: credentials,
headers: { "REMOTE_ADDR" => "10.0.0.#{i}" }
end
post "/users/sign_in",
params: credentials,
headers: { "REMOTE_ADDR" => "10.0.0.99" }
expect(response.status).to eq(429)
end
end
describe "scanner ban" do
it "blocklist een IP dat /wp-login.php opvraagt" do
get "/wp-login.php"
expect(response.status).to eq(403)
# Bevestig dat ze daadwerkelijk gebanned zijn, niet alleen deze request afgewezen.
get "/"
expect(response.status).to eq(403)
end
end
end
De Rack::Attack.reset! en verse MemoryStore in before zijn wat deze tests geïsoleerd houdt — zonder deze lekt een throttle die in één test getriggerd wordt in de volgende, en wordt je suite volgorde-afhankelijke ruis. Gerelateerd: mijn post over Rails system tests met Capybara heeft meer over HTTP-boundary tests deterministisch houden in CI.
Observability: Log, Notify, Alert
Rails Rack::Attack emit ActiveSupport::Notifications events voor elke throttled, blocked of safelisted request. Subscribe één keer en pipe ze in je logging- en metrics-stack:
# config/initializers/rack_attack_notifications.rb
ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |_name, _start, _finish, _id, payload|
req = payload[:request]
Rails.logger.warn(
event: "rack_attack.throttle",
rule: req.env["rack.attack.matched"],
ip: req.ip,
path: req.path,
user_agent: req.user_agent
)
StatsD.increment("rack_attack.throttled", tags: ["rule:#{req.env['rack.attack.matched']}"])
end
ActiveSupport::Notifications.subscribe("blocklist.rack_attack") do |_name, _start, _finish, _id, payload|
req = payload[:request]
Rails.logger.warn(event: "rack_attack.block", ip: req.ip, path: req.path)
StatsD.increment("rack_attack.blocked")
end
Twee dingen worden mogelijk zodra deze events stromen. Ten eerste kun je een Grafana-paneel bouwen van “throttles per regel per uur” en zie je aanvalsgolven in real time. Ten tweede kun je alerten op de afgeleide — een plotselinge 20x spike in rack_attack.throttled is bijna altijd het begin van een incident, en een Pagerduty-ping krijgen in de eerste minuut koopt je een uur voorsprong op het opmerken wanneer een klant klaagt. Voor diepere LLM-observability, zie Rails LLM observability met Langfuse — dezelfde instrumentatie-instincten zijn van toepassing.
Veelgemaakte Rails Rack::Attack Fouten Die Ik Nog Steeds Zie
Een korte lijst van de failure modes die ik meer dan één keer heb gedebugd:
Rails.cachegebruiken terwijl het:memory_storeis in productie. Counters worden niet gedeeld tussen workers; de throttle doet niets. Verifieer metRails.cache.classin de productie-console.ActionDispatch::RemoteIpniet configureren achter een proxy.req.ipretourneert het IP van de load balancer en één throttle legt iedereen stil. Zetconfig.action_dispatch.trusted_proxiescorrect.- Throttlen op session ID of CSRF token. Deze rouleren vrij en geven aanvallers gratis bypass. Throttle op wat een aanvaller niet makkelijk kan rouleren: authenticated user ID, API key, target email.
- Geen
error_handlerop de cache. Redis-hikjes propageren als 500’s en halen de hele app onderuit. Fail open, altijd. - Regels die loggen maar niet getest zijn. Elke regel heeft minstens één RSpec-test nodig die bewijst dat hij afgaat, en één die bewijst dat hij niet afgaat op legitiem verkeer.
Veelgestelde Vragen
Hoe verschilt Rails Rack::Attack van de ingebouwde rate limiter in Rails 8?
Rails 8 shipte een kleine ActionController::RateLimit concern die prima is voor ad-hoc bescherming op een specifieke controller action. Rails Rack::Attack opereert op de Rack-laag voordat controllers laden, ondersteunt blocklists en safelists, heeft fail2ban semantiek, werkt over elke framework-component (Action Cable, Rails engines, gemounte Rack apps), en heeft tien jaar production hardening. Gebruik de ingebouwde voor een snelle controller-level cap; gebruik Rails Rack::Attack voor wat je een echte beschermingslaag zou noemen.
Moet ik Rack::Attack gebruiken als ik al Cloudflare rate limiting heb?
Ja, en behandel ze als gelaagde verdedigingen. Cloudflare handelt volumetrische aanvallen en pattern-based bot detectie goedkoop af aan de edge. Rails Rack::Attack handelt application-aware limieten — throttlen op API key, op target email, op authenticated user — waar Cloudflare niet in kan kijken omdat ze decrypted session state vereisen. Beide zijn goedkoop; draai beide.
Wat is de juiste periode voor een Rails Rack::Attack throttle?
Kort genoeg dat een burst meteen wordt gevangen, lang genoeg dat een legitieme piek van een echte gebruiker hem niet triggert. Mijn defaults: login op IP op 5/20s, login op email op 5/60s, signup op IP op 3/uur, API op key op 300/minuut. Koppel altijd een snelle throttle aan een trage companion (bv. 3/uur en 20/dag voor signups) om slow-drip aanvallers te vangen.
Werkt Rack::Attack met Action Cable en WebSockets?
Ja voor de initiële HTTP upgrade request, maar niet voor individuele berichten zodra de WebSocket vaststaat — de middleware-stack draait alleen op de initiële request. Rate limiting per bericht moet in je Action Cable channel leven, typisch als een Redis-backed counter gesleuteld op de connection identifier. Houd authenticatie-niveau throttling in Rails Rack::Attack en message-niveau throttling in het channel.
Hulp nodig bij het harden van een Rails-app tegen misbruik, scraping of credential stuffing voordat het productie raakt? TTB Software shipt al negentien jaar productie Rails-systemen, en Rack::Attack is het eerste dat we installeren. We reviewen je setup of ontwerpen er eentje vanaf nul.
Related Articles
Rails pgvector: Semantische Zoek en RAG met PostgreSQL voor LLM-Applicaties
Rails pgvector gids: bouw semantische zoek en RAG op PostgreSQL met embeddings, HNSW-indexen en hybride retrieval. Pr...
Rails API-authenticatie: JWT, Sessiecookies en API-sleutels — Wanneer Gebruik Je Wat
Rails API-authenticatie in 2026: JWT, sessiecookies en API-sleutels vergeleken — beveiligingsafwegingen, Rails 8-code...
Rails Passkeys: WebAuthn Passwordless Authenticatie met webauthn-ruby in Rails 8
Rails passkeys en WebAuthn in Rails 8: bouw productie-passwordless authenticatie met webauthn-ruby, van registratiefl...