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