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 sendSec-Fetch-Siteheader, falling back to token verificationcsrf_request_blocked.action_controller: Request was blocked due to CSRF validation failurecsrf_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-Siteindicates 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.