Rate Limiting, IP Allowlists & a Request Audit Trail
⚠️ Cookbook example — not live code. (KEEP THIS CALLOUT.) Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.
Two halves of the same job: stop abusive traffic, and know afterwards what
happened. Rack::Attack throttles login brute-force and request floods and bans scanners
at the middleware layer, before Rails ever loads a controller. A UserEvent audit trail
records every state-changing request with the user, the IP, the path and the parameters,
and puts it behind an admin screen you can filter.
Read the Gotchas before you trust any of it. This pattern has two failure modes that leave a perfect-looking configuration file doing absolutely nothing, with no error anywhere. Both were found live on a real customer box that believed it was rate limited.
When to use: any app with a public login page — which is every app with a login page. Also whenever a customer asks "who changed this record?" or a security reviewer asks about brute-force protection. When not to: an app behind a corporate VPN with no public surface, where the audit trail alone is the useful half.
The 80/20 in one breath
- Write a
Rack::Attackconfig: throttle logins per IP and per email, throttle overall requests per IP, safelist localhost and any trusted office IPs. - Point
Rack::Attack.cache.storeat Redis explicitly. The default isRails.cache, which is aNullStoreon a Leo box — counters vanish and every throttle no-ops. - Make sure the file actually loads. On a Leo box, most of
config/initializers/is not mounted into the container. Verify with a runner, never by reading the file on disk. - Add a
user_eventstable plus aUserEvents::Trackerthat never raises, and call it from anafter_actionfor write verbs only. - Add an admin index over
user_eventswith filters for user, event, date and controller. - Optionally add a country allowlist driven by a CDN header, defaulting to fail-open.
Layer 1 — Rack::Attack
# config/initializers/rack_attack.rb
# frozen_string_literal: true
# Guard so the file is harmless if the gem is ever absent.
return unless defined?(Rack::Attack) && Rack::Attack.respond_to?(:throttle)
require "ipaddr"
class Rack::Attack
### Cache store — READ THIS BEFORE ANYTHING ELSE ############################
# Throttle and Fail2Ban counters MUST live in a shared, persistent store.
# Rack::Attack defaults to Rails.cache, which on a Leo box is NullStore:
# every increment is discarded, every request looks like the first one, and
# NOTHING below has any effect. There is no warning. Point it at Redis.
begin
# pool: false is required on this image — ActiveSupport 7.2's pooled
# RedisCacheStore construction is incompatible with connection_pool 3.x and
# raises "wrong number of arguments (given 1, expected 0)" at boot.
self.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
url: ENV.fetch("REDIS_URL", "redis://redis:6379/1"),
pool: false
)
rescue => e
# Last-resort boot guard. MemoryStore counts per Puma process, so limits
# are effectively multiplied by your worker count — degraded, not off.
Rails.logger.error("[Rack::Attack] Redis cache store init failed " \
"(#{e.class}: #{e.message}); falling back to MemoryStore.")
self.cache.store = ActiveSupport::Cache::MemoryStore.new
end
### Safelists — evaluated FIRST; they beat throttles AND blocklists #########
# Without this, your own health checks and any in-container curl can ban you.
safelist("allow-localhost") { |req| ["127.0.0.1", "::1"].include?(req.ip) }
# Trusted office / VPN / partner addresses. Use CIDR ranges, not bare strings,
# so a customer on a /24 doesn't get half their staff throttled.
SAFE_RANGES = [
"203.0.113.0/24", # head office
].map { |c| IPAddr.new(c) }
safelist("allow-trusted-ips") do |req|
ip = (IPAddr.new(req.ip) rescue nil) # req.ip can be malformed; never raise here
ip && SAFE_RANGES.any? { |range| range.include?(ip) }
end
### General per-IP flood ####################################################
# Exclude assets and websockets: a real page load pulls 60-90 assets, and a
# long-lived /cable reconnect loop can post hundreds of requests legitimately.
# Counting those is how you throttle your best customer.
throttle("req/ip", limit: 300, period: 5.minutes) do |req|
req.ip unless req.path.start_with?("/assets", "/packs", "/cable")
end
### Login brute-force #######################################################
# Two throttles, deliberately. Per-IP stops one machine guessing many
# passwords; per-email stops a botnet spreading guesses for ONE account across
# many IPs, which the per-IP rule cannot see.
throttle("logins/ip", limit: 5, period: 20.seconds) do |req|
req.ip if req.path == "/users/sign_in" && req.post?
end
throttle("logins/email", limit: 5, period: 20.seconds) do |req|
if req.path == "/users/sign_in" && req.post?
# Normalize, or "Alice@x.com " and "alice@x.com" count as two accounts
# and the attacker gets double the attempts for free.
req.params.dig("user", "email").to_s.downcase.gsub(/\s+/, "").presence
end
end
### Password-reset flooding (an email-bomb vector, not just a login one) ####
throttle("password_resets/ip", limit: 5, period: 1.minute) do |req|
req.ip if req.path == "/users/password" && req.post?
end
### Auto-ban vulnerability scanners #########################################
# 2 hits on a known recon path within 10 minutes => 1-hour 403 for that IP.
# Matches recon paths only, so it can never catch a real app route.
SCANNER_PATHS = %r{
\.(php|aspx?|env|git|sql|bak|old|ini|sh)\b
| /(wp-admin|wp-login|xmlrpc|adminer|phpmyadmin|phpinfo|\.env|\.git|\.aws)
}xi
blocklist("fail2ban/scanners") do |req|
Rack::Attack::Fail2Ban.filter("scanner-#{req.ip}", maxretry: 2, findtime: 10.minutes, bantime: 1.hour) do
SCANNER_PATHS.match?(req.path)
end
end
### Responses ###############################################################
# Retry-After is not decoration: well-behaved clients honour it, and its
# absence turns a throttle into an unexplained failure for legitimate users.
self.throttled_responder = lambda do |request|
md = request.env["rack.attack.match_data"] || {}
retry_after = (md[:period].to_i - (md[:epoch_time].to_i % md[:period].to_i)) rescue 60
[429,
{ "Content-Type" => "text/plain", "Retry-After" => retry_after.to_s },
["Rate limit exceeded. Please retry later.\n"]]
end
self.blocklisted_responder = lambda do |_request|
[403, { "Content-Type" => "text/plain" }, ["Forbidden\n"]]
end
end
### Log every match, or you are tuning blind ##################################
# Without this you cannot tell "no attacks" from "no rules loaded" — the two
# look identical. Grep the container logs for "[Rack::Attack]".
ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, payload|
req = payload[:request]
next unless req && %i[throttle blocklist].include?(req.env["rack.attack.match_type"])
Rails.logger.warn(
"[Rack::Attack] #{req.env['rack.attack.match_type']} " \
"rule=#{req.env['rack.attack.matched']} ip=#{req.ip} " \
"#{req.request_method} #{req.path}"
)
end
Rails.application.config.middleware.use Rack::Attack
Verify it is actually live — this is not optional
The middleware being present proves nothing: the gem's railtie inserts
Rack::Attack into the stack whether or not your rules ever loaded.
docker compose exec -T llamapress bin/rails runner '
puts "cache store: #{Rack::Attack.cache.store.class}"
puts "throttles: #{Rack::Attack.throttles.keys.inspect}"
puts "safelists: #{Rack::Attack.safelists.keys.inspect}"
puts "blocklists: #{Rack::Attack.blocklists.keys.inspect}"
'
Empty arrays, or NullStore, mean you have zero protection. Then prove it end to end
by tripping a rule:
for i in $(seq 1 12); do
curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:3000/users/sign_in \
-d "user[email]=throttle-test@example.com&user[password]=wrong"
done; echo
# Expect the tail of that line to become 429.
Layer 2 — Country allowlist (optional)
A blunt instrument for customers who say "only our country should be able to reach this".
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
ALLOWED_COUNTRIES = %w[US AU DE].freeze
# Headers CDNs and proxies use to report the visitor's 2-letter country.
# CF-IPCountry is Cloudflare's.
COUNTRY_CODE_HEADERS = %w[
HTTP_CF_IPCOUNTRY HTTP_X_COUNTRY_CODE HTTP_X_APPENGINE_COUNTRY
].freeze
# Runs BEFORE authentication so a blocked location cannot even see the login
# page — otherwise you are only hiding the app, not the attack surface.
before_action :restrict_by_country
private
def restrict_by_country
return unless country_allowlist_enabled?
country = request_country_code
# FAIL OPEN. There is no GeoIP database in the image, so the country comes
# only from an upstream header. If the proxy stops sending it, failing
# closed locks out every user worldwide, including you.
return if country.blank?
return if ALLOWED_COUNTRIES.include?(country)
render plain: "Access from your location is not permitted.", status: :forbidden
end
# Env-gated so the capability can ship dark and be flipped on per environment.
def country_allowlist_enabled?
ActiveModel::Type::Boolean.new.cast(ENV["ENABLE_COUNTRY_ALLOWLIST"])
end
def request_country_code
COUNTRY_CODE_HEADERS.each do |header|
value = request.headers[header]
return value.to_s.strip.upcase if value.present?
end
nil
end
end
Layer 3 — The audit trail: model & SQL
# db/migrate/20260101000000_create_user_events.rb
class CreateUserEvents < ActiveRecord::Migration[7.2]
def change
create_table :user_events do |t|
t.bigint :user_id # nullable on purpose: anonymous events matter too
t.string :session_id
t.string :event_name, null: false
t.string :source, default: "server", null: false
t.string :controller_name
t.string :action_name
t.string :path
t.string :ip_address
t.text :user_agent
t.text :referer
t.jsonb :metadata, default: {}, null: false
t.text :admin_notes # lets an admin annotate a suspicious event
t.timestamps
end
add_index :user_events, :user_id
add_index :user_events, :created_at
add_index :user_events, [:event_name, :created_at]
add_index :user_events, [:controller_name, :action_name]
add_index :user_events, :source
end
end
# app/models/user_event.rb
class UserEvent < ApplicationRecord
belongs_to :user, optional: true
SOURCES = %w[server browser].freeze
# Only constrains what an UNAUTHENTICATED browser endpoint may write, to stop
# log-spam injection. Server-side callers may log any event name.
TRACKABLE_EVENTS = %w[page_viewed record_opened export_clicked].freeze
validates :event_name, presence: true, length: { maximum: 200 }
validates :source, inclusion: { in: SOURCES }
scope :recent, -> { order(created_at: :desc) }
scope :by_event, ->(name) { where(event_name: name) if name.present? }
scope :by_source, ->(source) { where(source: source) if source.present? }
scope :by_controller, ->(name) { where(controller_name: name) if name.present? }
# Accepts a user id (all digits) or an email fragment; no-op when blank.
scope :for_user, ->(value) {
next all if value.blank?
if value.to_s.match?(/\A\d+\z/)
where(user_id: value)
else
joins(:user).where("users.email ILIKE ?", "%#{sanitize_sql_like(value.to_s)}%")
end
}
scope :on_or_after, ->(date) { where("user_events.created_at >= ?", date.beginning_of_day) if date.present? }
scope :on_or_before, ->(date) { where("user_events.created_at <= ?", date.end_of_day) if date.present? }
end
Layer 4 — The tracker
One rule governs this class: it must never raise. A logging failure that breaks a customer's save is worse than the missing log line.
# app/services/user_events/tracker.rb
module UserEvents
# Server-side entry point for logging events from anywhere — controllers, jobs,
# services.
#
# UserEvents::Tracker.track("invoice_sent", user: current_user, request: request,
# metadata: { invoice_id: 42 })
class Tracker
MAX_METADATA_BYTES = 8_192
# Matched case-insensitively ANYWHERE in the key. Never persist secrets or
# credentials into a table that admins browse and support staff screenshot.
SENSITIVE_KEY_PATTERN = /password|token|secret|api[_-]?key|card|cvv|cvc|ssn|auth|credential|cookie|session/i
def self.track(event_name, user: nil, source: "server", request: nil, metadata: {}, session_id: nil)
attrs = {
event_name: event_name.to_s,
source: source.to_s,
user_id: user&.id,
session_id: session_id,
metadata: sanitize_metadata(metadata)
}
if request
# remote_ip, NOT request.ip — remote_ip walks X-Forwarded-For and honours
# trusted proxies, so behind Caddy/Cloudflare you record the real client
# instead of your own reverse proxy on every single row.
attrs[:ip_address] = request.remote_ip
attrs[:user_agent] = request.user_agent
attrs[:referer] = request.referer
attrs[:path] = request.path
attrs[:controller_name] = request.params[:controller]
attrs[:action_name] = request.params[:action]
attrs[:session_id] ||= (request.session.id.to_s rescue nil)
end
# create, not create! — a validation failure must not raise into the caller.
UserEvent.create(attrs)
rescue => e
Rails.logger.error("[UserEvents::Tracker] #{e.class}: #{e.message}")
nil
end
# Plain hash, string keys, sensitive keys stripped, total size capped.
def self.sanitize_metadata(metadata)
hash =
case metadata
when ActionController::Parameters then metadata.to_unsafe_h
when Hash then metadata
else return {}
end
cleaned = hash.each_with_object({}) do |(key, value), acc|
key = key.to_s
next if key.match?(SENSITIVE_KEY_PATTERN)
acc[key] = scrub_value(value)
end
# A single file upload or pasted spreadsheet can otherwise write megabytes
# per row and bloat the table until the admin page times out.
if cleaned.to_json.bytesize > MAX_METADATA_BYTES
return { "_truncated" => true, "_note" => "metadata exceeded #{MAX_METADATA_BYTES} bytes and was dropped" }
end
cleaned
rescue => e
Rails.logger.error("[UserEvents::Tracker] metadata sanitize failed: #{e.class}: #{e.message}")
{}
end
# Recursive, because sensitive keys hide in nested params too.
def self.scrub_value(value)
case value
when Hash
value.each_with_object({}) do |(k, v), acc|
k = k.to_s
next if k.match?(SENSITIVE_KEY_PATTERN)
acc[k] = scrub_value(v)
end
when Array then value.first(100).map { |v| scrub_value(v) }
when String then value.length > 2_000 ? value[0, 2_000] : value
else value
end
end
private_class_method :scrub_value
end
end
Layer 5 — Automatic tracking from ApplicationController
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
# Log every successful data-changing request. Tracking never raises, so it
# cannot break the request it is recording.
after_action :track_user_activity
# Write verbs only. Logging GETs would bury the signal in page views and grow
# the table by an order of magnitude for no audit value.
TRACKED_HTTP_METHODS = %w[POST PUT PATCH DELETE].freeze
# Controllers with their own, more descriptive tracking — excluded to avoid
# duplicate rows. Add your sign-in controller here and log it explicitly with
# a real event name instead.
UNTRACKED_CONTROLLER_PATHS = %w[api/user_events users/sessions].freeze
private
def track_user_activity
return unless TRACKED_HTTP_METHODS.include?(request.request_method)
# Successful or redirect only. This is what keeps failed validations (422)
# out of the audit trail — a rejected save did not change anything.
return unless response.successful? || response.redirect?
return if UNTRACKED_CONTROLLER_PATHS.include?(controller_path)
UserEvents::Tracker.track(
"#{controller_path}##{action_name}",
user: (current_user if respond_to?(:current_user)),
request: request,
metadata: activity_metadata
)
rescue => e
Rails.logger.error("[track_user_activity] #{e.class}: #{e.message}")
end
def activity_metadata
meta = {}
meta[:resource_id] = params[:id] if params[:id].present?
body = params.except(:controller, :action, :format, :id,
:authenticity_token, :_method, :utf8, :commit, :api_token)
# Sensitive keys are stripped downstream by the Tracker — do not rely on
# this list alone.
meta[:params] = body.to_unsafe_h if body.respond_to?(:to_unsafe_h)
meta
rescue
{}
end
end
# app/controllers/admin/user_events_controller.rb
# Admin "Activity Logs" — browse, filter and annotate the event log.
class Admin::UserEventsController < Admin::BaseController
def index
scope = UserEvent.includes(:user) # includes(:user) or the index N+1s
.by_event(params[:event_name])
.by_source(params[:source])
.by_controller(params[:controller_name])
.for_user(params[:user])
.on_or_after(parse_date(params[:start_date]))
.on_or_before(parse_date(params[:end_date]))
.recent
@pagy, @events = pagy(scope, limit: 50)
@event_names = UserEvent.distinct.order(:event_name).pluck(:event_name)
end
def show
@event = UserEvent.find(params[:id])
end
private
def parse_date(value)
return nil if value.blank?
Date.parse(value)
rescue ArgumentError
nil # a half-typed date filter must not 500 the page
end
end
Gotchas (the hard-won stuff)
🛑 On a Leo box,
config/initializers/is mostly NOT mounted into the container. Checkdocker-compose.yml: older templates bind-mount only individual initializer files (often justdevise.rbandllama_bot_rails.rb). Arack_attack.rbyou add torails/config/initializers/then exists on the host, reads perfectly, is committed, and never loads. Verified on a real customer box: the file defined seven throttles and an IP allowlist, while the running app reportedRack::Attack.throttles == {}andsafelists == {}. Confirm the file is inside the container before you believe anything:docker compose exec -T llamapress ls -la /rails/config/initializers/If it is missing, add the bind-mount to
docker-compose.ymland recreate the service (docker compose up -d --force-recreate llamapress). Note thatdocker-compose.ymlis on the platform update allowlist, so a per-box hand-edit can be overwritten by a later update — re-verify after any version push.🛑
Rails.cacheis aNullStoreon a Leo box, and Rack::Attack defaults to it. Every counter increment is silently discarded, so no throttle ever reaches its limit, no matter how correct the rules are. This is the second independent way to have "rate limiting" that does nothing. SetRack::Attack.cache.storeto Redis explicitly, as in Layer 1, and checkRack::Attack.cache.store.classin a runner.Middleware present ≠ rules loaded. The rack-attack railtie inserts the middleware on its own.
Rails.application.middlewarelistingRack::Attacktells you nothing about whether your config ran. Onlythrottles.keys/safelists.keysdo.The generic SQL-injection Fail2Ban filter bans real users. A rule that matches an apostrophe in the query string (
/(\%27)|(\')|(\-\-)/) fires on any legitimate search forO'Brienor a hyphenated part number, and the third one earns an hour-long 403. Prefer a path-based scanner rule (SCANNER_PATHSabove), which can only match recon URLs, and treat the query-string filter as opt-in for apps with no free-text search.Safelist before you throttle, and safelist localhost first. Safelists beat both throttles and blocklists. Skip this and your own health check, uptime monitor, or in-container
curlcan trip a ban and take the app down from the inside.Exclude
/assets,/packsand/cablefrom the global per-IP throttle. One real page load pulls 60–90 assets and a websocket reconnect loop can post hundreds of requests in minutes. Counting those throttles your most active customer first — they generate the most traffic by definition.Throttle logins by IP and by email. They catch different attacks: per-IP stops one machine trying many passwords; per-email stops many machines trying one account. Normalize the email (downcase, strip whitespace) or the attacker gets a fresh bucket per capitalization.
request.remote_ip, notrequest.ip, in application code.remote_ipwalksX-Forwarded-Forand respects trusted proxies. Behind Caddy or Cloudflare,iprecords your own reverse proxy on every row, and an audit trail where every request came from172.18.0.1is worthless. (Inside a Rack::Attack block,req.ipis the right call — that is Rack's own already-resolved value.)Fail OPEN on the country check. There is no GeoIP database in the image, so the country comes only from an upstream CDN header. If that proxy is removed or reconfigured, failing closed locks out every user on earth, including whoever would fix it. Gate the whole feature behind an env var too.
Scrub nested params, not just top-level ones.
user[password]andpayment[card][cvv]both hide one level down. The recursivescrub_valueabove is the reason the audit table is safe to browse; a flat key filter is not.Cap the metadata size. A file upload or pasted spreadsheet in the params will write megabytes into a single
jsonbcolumn, and a few hundred of those make the admin index time out.Log every throttle match. Without the
ActiveSupport::Notificationssubscriber, "we are under no attack" and "our rules never loaded" produce identical evidence: silence.Plan for table growth.
user_eventsgrows with write traffic forever. Add a retention job (UserEvent.where("created_at < ?", 1.year.ago).delete_all) before the table, not after it hits tens of millions of rows.
Files this pattern touches
config/initializers/rack_attack.rb
db/migrate/20260101000000_create_user_events.rb
app/models/user_event.rb
app/services/user_events/tracker.rb
app/controllers/application_controller.rb
app/controllers/admin/user_events_controller.rb
app/views/admin/user_events/index.html.erb
app/views/admin/user_events/show.html.erb
config/routes.rb
docker-compose.yml # only if the initializer isn't mounted — see Gotchas
How to adapt to your schema
- Fix the paths first. Every throttle keys off a literal path. If you are not on
stock Devise, replace
/users/sign_inand/users/passwordwith your real routes — a typo produces a rule that matches nothing and reports no error. Confirm each one withbin/rails routes | grep sign_in. - Set the limits from your own traffic. Query the audit table before guessing:
UserEvent.where("created_at > ?", 1.day.ago).group(:ip_address).count.values.maxtells you what a busy legitimate user actually does. - Add throttles for anything expensive or public. Endpoints that send email, call a paid API, upload files, or create accounts each deserve their own rule — a global per-IP limit is far too loose to protect a per-call cost.
- Fill in
SAFE_RANGESwith the customer's office and VPN CIDR blocks. This is the "IP allowlist" most customers mean when they ask for one. - Log domain events, not just controller actions. The
after_actiongives youprojects#updatefor free; callUserEvents::Tracker.track("contract_signed", ...)by hand where the business meaning is what an auditor will look for. - Safe to drop for small apps: the country allowlist, the Fail2Ban scanner rule, and the admin annotation column. Never drop the Redis cache store or the login throttles — those are the parts doing the actual work.