Monitor CSRF Attacks in Production with Rails Notifications

Rails CSRF protection has always logged warnings when something suspicious happens. But logs are easy to miss. An upcoming change converts these warnings into ActiveSupport notifications, letting you subscribe to CSRF events and build real security monitoring.

What’s Changing

Rails will emit three notification events for CSRF-related situations:

  • csrf_token_fallback.action_controller: Browser didn’t send Sec-Fetch-Site header, falling back to token verification
  • csrf_request_blocked.action_controller: Request was blocked due to CSRF validation failure
  • csrf_javascript_blocked.action_controller: Cross-origin JavaScript request was blocked

Each event includes useful context in its payload:

{
  request: request,           # The full ActionDispatch::Request
  controller: "UsersController",
  action: "update",
  sec_fetch_site: "cross-site",  # or "same-origin", "same-site", "none", nil
  message: "..."              # Human-readable description (for blocked events)
}

Why This Matters

CSRF attacks are real. When someone attempts one against your app, you want to know about it, not discover it weeks later while grep-ing through logs.

With notifications you can alert when blocked requests spike, track which endpoints get targeted and from where, pipe events into Datadog or your SIEM, or debug why your own forms are failing CSRF validation.

Building a CSRF Monitor

A subscriber that tracks events and alerts on suspicious patterns:

# config/initializers/csrf_monitor.rb
class CsrfMonitor < ActiveSupport::Subscriber
  attach_to :action_controller

  def csrf_token_fallback(event)
    # Browser didn't send Sec-Fetch-Site header
    # Common for older browsers, curl, or non-browser clients
    track_event("fallback", event.payload)
  end

  def csrf_request_blocked(event)
    # Actual CSRF validation failure - potential attack
    track_event("blocked", event.payload)
    alert_if_suspicious(event.payload)
  end

  def csrf_javascript_blocked(event)
    # Someone tried to load your JS from another origin
    track_event("js_blocked", event.payload)
  end

  private

  def track_event(type, payload)
    Rails.logger.tagged("CSRF") do
      Rails.logger.warn({
        type: type,
        controller: payload[:controller],
        action: payload[:action],
        sec_fetch_site: payload[:sec_fetch_site],
        ip: payload[:request]&.remote_ip,
        origin: payload[:request]&.origin,
        user_agent: payload[:request]&.user_agent
      }.to_json)
    end

    # Send to your metrics system
    StatsD.increment("csrf.#{type}", tags: [
      "controller:#{payload[:controller]}",
      "action:#{payload[:action]}"
    ]) if defined?(StatsD)
  end

  def alert_if_suspicious(payload)
    # Alert if we see multiple blocked requests from the same IP
    # Note: requires Redis or Memcached cache store for atomic increment
    ip = payload[:request]&.remote_ip
    return unless ip

    cache_key = "csrf_blocked:#{ip}"
    count = Rails.cache.increment(cache_key, 1, expires_in: 1.hour, raw: true) || 1

    if count >= 10
      SecurityAlertJob.perform_later(
        type: "csrf_attack_suspected",
        ip: ip,
        count: count,
        last_target: "#{payload[:controller]}##{payload[:action]}"
      )
    end
  end
end

Sending Events to External Services

Datadog

class CsrfDatadogSubscriber < ActiveSupport::Subscriber
  attach_to :action_controller

  def csrf_request_blocked(event)
    Datadog::Tracing.trace("csrf.blocked") do |span|
      span.set_tag("controller", event.payload[:controller])
      span.set_tag("action", event.payload[:action])
      span.set_tag("origin", event.payload[:request]&.origin)
      span.set_tag("sec_fetch_site", event.payload[:sec_fetch_site])
    end
  end
end

Error Tracking Services

class CsrfErrorSubscriber < ActiveSupport::Subscriber
  attach_to :action_controller

  def csrf_request_blocked(event)
    ErrorTracker.capture("CSRF request blocked",
      level: :warning,
      tags: {
        controller: event.payload[:controller],
        action: event.payload[:action]
      },
      context: {
        origin: event.payload[:request]&.origin,
        sec_fetch_site: event.payload[:sec_fetch_site],
        ip: event.payload[:request]&.remote_ip
      }
    )
  end
end

Swap ErrorTracker.capture for your actual service (Sentry, Honeybadger, Bugsnag, whatever you use).

One gotcha: without a custom fingerprint (something like ["csrf", controller, action]), you’ll end up with thousands of duplicate issues. High-traffic apps should also sample to avoid burning through quotas.

Avoiding Duplicate Log Entries

Rails’ built-in LogSubscriber already listens for these CSRF events and logs warnings. If you build your own subscriber, you’ll get duplicate entries: once from Rails, once from yours.

To disable Rails’ default CSRF logging:

# config/application.rb
config.action_controller.log_warning_on_csrf_failure = false

This only kills the built-in log output. Your custom subscribers still get everything. The notifications fire regardless.

Disable it when you’re handling logging yourself, forwarding to external services, or just tired of noisy fallback events from API clients and old browsers.

Understanding the Events

Token Fallback

The csrf_token_fallback event fires when a browser doesn’t send the Sec-Fetch-Site header. Modern browsers send this header automatically, so this event typically means:

  • Older browser (pre-2020)
  • API client or script (curl, Postman, etc.)
  • Browser with privacy extensions that strip headers

This isn’t necessarily an attack. It’s informational. But if you see this from what should be a modern browser, investigate.

Request Blocked

The csrf_request_blocked event is the important one. This fires when:

  • CSRF token is missing entirely
  • CSRF token is invalid or expired
  • Origin header doesn’t match allowed origins
  • Sec-Fetch-Site indicates cross-site and origin isn’t trusted

Legitimate causes include expired sessions, double-form-submissions, or misconfigured CORS. Illegitimate causes are actual CSRF attacks.

JavaScript Blocked

The csrf_javascript_blocked event fires when someone tries to include your JavaScript from another origin. This is almost always suspicious, either an attack or a misconfigured CDN.

Silencing Noisy Events

Some endpoints trigger CSRF events during normal operation: mobile apps refreshing tokens, JavaScript forms with race conditions, legacy integrations. You can filter them out:

class CsrfMonitor < ActiveSupport::Subscriber
  attach_to :action_controller

  IGNORED_ENDPOINTS = [
    ["SessionsController", "refresh"],     # Mobile app token refresh
    ["Api::V1::SyncController", nil],      # nil matches any action
    ["LegacyIntegrationController", "callback"]
  ].freeze

  def csrf_request_blocked(event)
    return if ignored_endpoint?(event.payload)
    # ... handle event
  end

  private

  def ignored_endpoint?(payload)
    IGNORED_ENDPOINTS.any? do |controller, action|
      payload[:controller] == controller &&
        (action.nil? || payload[:action] == action)
    end
  end
end

Testing Your Subscriber

# test/subscribers/csrf_monitor_test.rb
require "test_helper"

class CsrfMonitorTest < ActiveSupport::TestCase
  MockRequest = Struct.new(:remote_ip, :origin, :user_agent, keyword_init: true)

  test "tracks blocked requests" do
    payload = {
      controller: "UsersController",
      action: "update",
      sec_fetch_site: "cross-site",
      request: mock_request(remote_ip: "1.2.3.4"),
      message: "CSRF token verification failed"
    }

    assert_logs_matching(/CSRF.*UsersController/) do
      ActiveSupport::Notifications.instrument(
        "csrf_request_blocked.action_controller",
        payload
      )
    end
  end

  test "alerts after repeated blocked requests from same IP" do
    payload = {
      controller: "UsersController",
      action: "update",
      request: mock_request(remote_ip: "1.2.3.4")
    }

    assert_enqueued_with(job: SecurityAlertJob) do
      10.times do
        ActiveSupport::Notifications.instrument(
          "csrf_request_blocked.action_controller",
          payload
        )
      end
    end
  end

  private

  def mock_request(attrs = {})
    MockRequest.new(**{
      remote_ip: "127.0.0.1",
      origin: "https://evil.com",
      user_agent: "Mozilla/5.0"
    }.merge(attrs))
  end

  def assert_logs_matching(pattern, &block)
    old_logger = Rails.logger
    log_output = StringIO.new
    Rails.logger = ActiveSupport::TaggedLogging.new(Logger.new(log_output))

    yield

    assert_match pattern, log_output.string
  ensure
    Rails.logger = old_logger
  end
end

Triggering Events in Development

Fire events manually from the Rails console to verify your subscriber works:

# Simulate a blocked CSRF request
ActiveSupport::Notifications.instrument(
  "csrf_request_blocked.action_controller",
  controller: "TestController",
  action: "create",
  sec_fetch_site: "cross-site",
  message: "CSRF token verification failed"
)

# Simulate a token fallback
ActiveSupport::Notifications.instrument(
  "csrf_token_fallback.action_controller",
  controller: "TestController",
  action: "update",
  sec_fetch_site: nil
)

Or trigger a real CSRF failure by submitting a form without a valid token:

curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "user[name]=test"

Wrapping Up

This ships in Rails 8.2. If you want it now, point your Gemfile at main. PR #56355 has the details.