LaunchKit
← All posts
· 15 min read · by The LaunchKit team · 0 views

Rails 8.2 CSRF moves to Sec-Fetch-Site

Anyone with protect_from_forgery in ApplicationController and no with: argument is going to get a deprecation warning the day they upgrade to Rails 8.2, and the line above it in the log will be about a header most Rails applications have never read. Both come from the same change: CSRF protection in Rails is moving off the authenticity token and onto Sec-Fetch-Site.

Rails 8.2 does not exist as a gem yet. gem list -r -a rails on this laptop returns 8.1.4 at the head of the list, published 2026-09-24, and the edge guides carry "Edge" in the version selector. Everything below was run against a clone of rails/rails at 7d52e0123, dated Fri Sep 25 2026, whose RAILS_VERSION reads 8.2.0.alpha. The controllers were booted directly off that checkout with $LOAD_PATH pointed at actionpack/lib, driven through Rack::Test on Ruby 4.0.5, macOS arm64-darwin25. The browser measurements are Chrome 153.0.8010.53 against a 30-line TCPServer that does nothing but write the request headers to a file. File and line references are to that checkout, so check them against your own clone rather than against the released gem, which has none of this.

What the check actually is, in one method

verified_request? at request_forgery_protection.rb:615 still exempts GET, HEAD and QUERY, and still requires valid_request_origin?. What changed is the third clause, which used to compare a token and now dispatches on a strategy:

def verified_request_for_forgery_protection?
  if using_header_only_for_forgery_protection?
    verified_via_header_only?
  else
    verified_with_legacy_token?
  end
end

def verified_via_header_only?
  case sec_fetch_site_value
  when "same-origin", "same-site"
    true
  when "cross-site"
    origin_trusted?
  when nil
    !request.ssl? && !ActionDispatch::Http::URL.secure_protocol
  else
    false
  end
end

Five outcomes, not two. sec_fetch_site_value at line 696 downcases and calls presence, so the value is normalised before the case sees it, and an empty header is nil rather than "". The else branch is the one worth staring at: anything that is not one of the three known values falls through to false, and "none" is one of those things. "none" is what a browser sends when the user typed the URL, hit a bookmark, or dragged a file into the window, and under :header_only it is a rejection.

Here is the full matrix, posted through Rack::Test against a controller declaring protect_from_forgery using: :header_only, with: :exception, over plain HTTP:

no Sec-Fetch-Site                 -> 200 created
Sec-Fetch-Site: same-origin       -> 200 created
Sec-Fetch-Site: same-site         -> 200 created
Sec-Fetch-Site: SAME-ORIGIN       -> 200 created
Sec-Fetch-Site: cross-site        -> InvalidCrossOriginRequest: Sec-Fetch-Site header (cross-site) indicates a cross-site request
Sec-Fetch-Site: none              -> InvalidCrossOriginRequest: Sec-Fetch-Site header is missing or invalid ("none")
Sec-Fetch-Site: banana            -> InvalidCrossOriginRequest: Sec-Fetch-Site header is missing or invalid ("banana")

The warning strings come from unverified_request_warning_message at line 544, and they are worth knowing by sight because they are the only thing that tells you which of the three failure modes you hit. "none" and "banana" produce the same message with a different value inside the inspect.

The hybrid strategy differs on exactly one leg

verified_with_legacy_token? at line 655 is the same case with the default arm replaced. Same-site and same-origin pass without a token, cross-site is rejected unless the origin is trusted, and only the missing-or-none case reaches any_authenticity_token_valid?:

The four arms of the Sec-Fetch-Site case in Rails 8.2, with the two verification strategies as two columns. The same-origin, same-site and cross-site arms land on one outcome card that spans both columns, because header_only and header_or_legacy_token agree there. Only the two lower arms split into separate cards: a missing header is decided by line 648 and an unknown value is always rejected under header_only, while header_or_legacy_token sends both of those arms into any_authenticity_token_valid?.

:header_or_legacy_token
  no header, no token       -> [WARN] Falling back to CSRF token verification for HybridController#create
                               [WARN] Can't verify CSRF token authenticity.
                            -> InvalidCrossOriginRequest
  Sec-Fetch-Site: cross-site, no token  -> InvalidCrossOriginRequest
  Sec-Fetch-Site: same-origin, no token -> 200 created

Note the third line. Under :header_or_legacy_token, a same-origin POST is accepted with no token at all, which means the hybrid strategy is not "old behaviour plus a header". It is the new behaviour with a token fallback bolted to one branch, and an application that was relying on the token being verified on every request has already stopped doing that the moment it loads 8.2, whatever its load_defaults says. ActionController::Base.forgery_protection_verification_strategy on a bare load prints :header_or_legacy_token (line 122). railties/lib/rails/application/configuration.rb:373 is where load_defaults 8.2 flips it to :header_only.

That fallback arm also instruments. instrument_csrf_event publishes csrf_token_fallback.action_controller, csrf_request_blocked.action_controller and csrf_javascript_blocked.action_controller, each carrying request, controller, action, sec_fetch_site and message. Subscribing to the first one for a week is the cheapest possible answer to "can we move to :header_only", and it is the thing to do before the upgrade rather than after.

Which clients never send the header

The MDN page calls Sec-Fetch-Site Baseline since March 2023 and a forbidden request header, meaning JavaScript cannot set it. Neither fact answers the question that matters, which is not "which browsers" but "which requests". The Fetch Metadata spec gates the whole family on the target, not the client: section 3 of the W3C draft opens the append algorithm with "If r's current URL is not an potentially trustworthy URL, return."

So it got run instead. One socket server bound to 0.0.0.0:4321, one page with a form POST and a fetch POST, reached twice from the same Chrome profile under two different hostnames:

POST via=curl        Sec-Fetch-Site=nil            UA="curl/8.7.1"
POST via=nethttp     Sec-Fetch-Site=nil            UA="Ruby"
POST via=python      Sec-Fetch-Site=nil            UA="Python-urllib/3.14"
POST via=fetch       Sec-Fetch-Site="same-origin"  Origin="http://127.0.0.1:4321"
POST via=form        Sec-Fetch-Site="same-origin"  Origin="http://127.0.0.1:4321"
POST via=fetch       Sec-Fetch-Site=nil            Origin="http://192.168.1.169:4321"
POST via=form        Sec-Fetch-Site=nil            Origin="http://192.168.1.169:4321"

Same browser, same page, same two buttons. 127.0.0.1 is a potentially trustworthy origin, so Chrome attaches the header; 192.168.1.169 is not, so it attaches nothing, and the Origin header is still there in both cases. The population that breaks under :header_only is therefore not "users on old browsers". It is every non-browser client you own: the health checker, the cron job posting to an internal endpoint, the mobile app's HTTP library, the partner service that POSTs a callback, and any colleague reaching your staging box by LAN address over plain HTTP.

Webhooks are the obvious member of that list and the one most likely to already be handled, because an unsigned webhook POST has been failing CSRF since long before this change. Receiving webhooks in Rails covers what that failure looks like from the sender's side. The ones that will surprise you are the internal callers nobody thought of as clients.

The dead end: a local reproduction proves nothing

The first row of that matrix is the trap. :header_only, a POST with no Sec-Fetch-Site, and the answer is 200 created. The obvious conclusion is that the using: option never took effect, and the obvious next step is to print forgery_protection_verification_strategy on that controller and confirm it, which it does: :header_only. Nothing is misconfigured. The when nil arm at line 648 is deliberate:

when nil
  !request.ssl? && !ActionDispatch::Http::URL.secure_protocol

A missing header is accepted when the request arrived over HTTP and the application does not force SSL. That arm is rails/rails#56580, "Make CSRF header-only protection compatible with local installs using HTTP", merged 2026-01-12, and its stated reason is the LAN case measured above: a developer on http://192.168.1.169:3000 gets no header from the browser and would otherwise be unable to submit any form. Flipping ActionDispatch::Http::URL.secure_protocol = true on the same plain-HTTP request turns the 200 into InvalidCrossOriginRequest: Sec-Fetch-Site header is missing or invalid (nil).

The consequence is the one that costs money. Your development machine cannot reproduce the production failure, because production has force_ssl on and development does not, and that single boolean is the difference between the header being optional and being mandatory. The test suite cannot reproduce it either: the generated config/environments/test.rb still ships config.action_controller.allow_forgery_protection = false at line 33 of the 8.2 template, so verified_request? short-circuits on !protect_against_forgery? before it ever looks at a header. A system test is the exception, and only by accident: Capybara.server_host is 127.0.0.1, which is potentially trustworthy, so a real browser driving a real form there does send the header. Green suite, green laptop, 422s in production.

same-site is not same-origin, and the Origin check is doing more work than you think

verified_via_header_only? accepts same-site. What that admits shows up with a second page served from http://127.0.0.1:4322 whose form posts to http://127.0.0.1:4321, clicked in the same browser:

POST via=other-port  Sec-Fetch-Site="same-site"  Sec-Fetch-Mode="navigate"  Origin="http://127.0.0.1:4322"

A different port is a different origin and the same site, so a cross-origin POST arrives wearing a value that :header_only treats as verified. The same is true of every subdomain you run. If blog.example.com is a CMS somebody else operates, its pages can POST to app.example.com and Sec-Fetch-Site will say same-site.

What closes that is valid_request_origin? at line 862, which is a separate clause of verified_request? and not part of the header switch at all:

forgery_protection_origin_check default = false        # bare ActionController::Base
same-site, Origin: https://evil.example, origin_check off -> 200
same-site, Origin: https://evil.example, origin_check ON  -> InvalidCrossOriginRequest: HTTP Origin header
                                                             (https://evil.example) didn't match
                                                             request.base_url (http://example.org)

The bare default is false, but no real application runs on the bare default: railties/lib/rails/application/configuration.rb:119 sets it to true from load_defaults 5.0, and bin/rails runner on a Rails 8.1.3.1 application here printed origin_check = true. So the subdomain hole is closed in practice, by a check that predates this feature by a decade. Worth knowing, because the first thing somebody does when a legitimate cross-origin callback starts failing is reach for forgery_protection_origin_check = false, and under :header_only that turns the same-site acceptance into a real hole rather than a theoretical one.

The supported way to allow a cross-site caller is trusted_origins, checked by origin_trusted? at line 690 with a plain include? against request.origin:

cross-site, Origin: https://accounts.google.com, trusted_origins: %w[https://accounts.google.com] -> 200
cross-site, Origin: https://accounts.evil.com,   same config                                      -> InvalidCrossOriginRequest

Exact string match, no wildcards, no suffix matching. https://accounts.google.com and https://accounts.google.com:443 are different strings.

Three deprecations, and only one of them is about the header

The deprecation the release notes lead with is protect_from_forgery called with no :with:

DEPRECATION WARNING: Calling `protect_from_forgery` without specifying a strategy is deprecated and
will default to `with: :exception` in a future version of Rails. To opt into the new behavior now,
use `config.action_controller.default_protect_from_forgery_with = :exception`. To silence this
warning without changing behavior, explicitly pass `protect_from_forgery with: :null_session`.

That one fires at class-definition time, once, and the fix is one word. What it is telling you is that the silent default has been :null_session (line 136) while config.action_controller.default_protect_from_forgery has always used :exception, so a great many applications have been swallowing CSRF failures into an empty session and rendering a confusing 500 three lines later instead of a clean 422. Pass with: :null_session if you want today's behaviour and silence; pass with: :exception if you want the behaviour the framework is moving to.

The second one is louder in a log and easier to miss in a diff:

DEPRECATION WARNING: `verify_authenticity_token` is deprecated and will be removed in a future Rails
version. To skip forgery protection, use `skip_forgery_protection` instead of skipping
`verify_authenticity_token` as this won't have any effect in a future Rails version.

skip_before_action :verify_authenticity_token still works today, and my POST through it returned 200. But the warning fires per request, not at boot, because the detection is a flag set in verify_authenticity_token (line 496) and read in verify_request_for_forgery_protection (line 516). Every API controller in your application carrying that line will print that paragraph on every single request. Replace it with skip_forgery_protection, which skips the right callback and also removes the Vary header discussed below.

The third is a constant:

ActionController::InvalidAuthenticityToken
# => ActionController::InvalidCrossOriginRequest
# DEPRECATION WARNING: ActionController::InvalidAuthenticityToken has been deprecated and will be
# removed in Rails 9.0. Use ActionController::InvalidCrossOriginRequest instead.

deprecate_constant at line 14 makes the old name an alias, so rescue_from ActionController::InvalidAuthenticityToken keeps catching the new exception and keeps rendering your custom 422 page. Nothing breaks. The warning fires when the constant resolves, which for a rescue_from in a class body means once at load.

Vary: Sec-Fetch-Site lands on every protected response

protect_from_forgery appends append_sec_fetch_site_to_vary_header (line 575) as an append_after_action, and it runs on every action, not only the verified ones:

GET /plain     Vary="Sec-Fetch-Site"
GET /skipped   Vary=nil

Any cached GET under a CDN now varies on a header whose value differs between a navigation, a fetch, and a bot that sends nothing, which fragments the cache key three ways for pages whose content does not depend on it at all. rails/rails#56522 stopped it being emitted under skip_forgery_protection, merged 2026-01-05, which is the whole of the mitigation currently in the framework. If you serve cacheable HTML from a controller that inherits protect_from_forgery, price that in.

The tokens are still in your HTML

:header_only does not stop Rails rendering authenticity tokens. token_tag in actionview/lib/action_view/helpers/navigation_helper.rb:587 is unchanged, and its condition is protect_against_forgery?, which has nothing to do with the verification strategy:

def token_tag(token = nil, form_options: {})
  if token != false && defined?(protect_against_forgery?) && protect_against_forgery?

So every form_with still carries a hidden input, csrf_meta_tags still emits csrf-param and csrf-token, and the session still stores a token nobody reads. Whatever you are buying with :header_only, it is not smaller pages or a lighter session.

What to do before you upgrade

Three greps, in this order, and none of them requires the 8.2 gem.

  1. grep -rn "protect_from_forgery" app/controllers and add an explicit with: to every call that has none. One word, no behaviour change, and it removes the deprecation from the upgrade entirely.
  2. grep -rn "verify_authenticity_token" app lib and replace every skip_before_action :verify_authenticity_token with skip_forgery_protection. Same intent, correct callback, and it drops the Vary header from those responses as a bonus.
  3. grep -rn "InvalidAuthenticityToken" app lib spec test and rename to InvalidCrossOriginRequest. The alias will outlive 8.2 but the rename is free now.

Then, on the day you take the gem, leave forgery_protection_verification_strategy alone. Taking the 8.2 gem without moving load_defaults gives you :header_or_legacy_token, which is the migration path: subscribe to csrf_token_fallback.action_controller, log the controller, action and sec_fetch_site it carries, and let it run under real traffic. Every event is a request that would have been a 422 under :header_only, named precisely enough to fix. Move to :header_only when that stream goes quiet, and not before.

Where I would land, and what would change it

:header_only is the right destination and a bad first move. The check is strictly better than the token for what it does: it cannot be leaked by a caching bug, it does not depend on a session, and it cannot be replayed, because the browser computes it per request and refuses to let script set it. But its failure mode is a silent 422 on a request nobody in the team classified as a browser request, and the two environments where you would notice, development and the test suite, are both structurally incapable of reproducing it. That asymmetry is what makes the fallback strategy the correct default for an existing application, and it is why I would not set :header_only on the same deploy as the version bump.

What would change my mind is a period of csrf_token_fallback.action_controller events that is genuinely empty in production, over a window long enough to include the monthly jobs. An empty fallback stream is direct evidence that no live caller depends on the token, and it is the only evidence that counts. A quiet staging environment is not it, because staging is where the LAN-address plain-HTTP escape hatch at line 648 is most likely to be hiding the problem.

The other position worth stating plainly: do not set forgery_protection_origin_check = false to make a cross-origin caller work. Under :header_only that removes the check that is actually containing the same-site acceptance, and it converts "a subdomain could forge requests in theory" into "a subdomain can forge requests". Use trusted_origins with the exact origin string instead.

What this post does not cover

Rails 8.2 is edge and the API can still move, so nothing here is a claim about the released gem. Safari and Firefox were not measured; every browser number above is Chrome 153.0.8010.53, and the generalisation past that rests on the Fetch Metadata spec rather than on anything that got run here. Hotwire Native and WKWebView were not tested either, which is the gap to close first before flipping a mobile-backed application to :header_only, and there is no data here on what a service worker's fetch reports for Sec-Fetch-Site when it replays a request.

Also absent: csrf_token_storage_strategy and the encrypted-cookie store, which is an older feature and unchanged here; the QUERY method exemption at line 626 and the _method=query tunnelling hole its comment describes, which deserves its own page; ActionController::API, which does not include RequestForgeryProtection at all and is therefore unaffected by every word above; and the CORS preflight interaction, since a cross-origin fetch that trips preflight fails at the browser before Rails sees a Sec-Fetch-Site value to judge. The Rails 7 to 8 upgrade post covers the load_defaults mechanics that decide which of these two strategies you get, and that mechanic is the whole of the upgrade decision here.

#rails #security

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.