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?:
: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.
grep -rn "protect_from_forgery" app/controllersand add an explicitwith:to every call that has none. One word, no behaviour change, and it removes the deprecation from the upgrade entirely.grep -rn "verify_authenticity_token" app liband replace everyskip_before_action :verify_authenticity_tokenwithskip_forgery_protection. Same intent, correct callback, and it drops theVaryheader from those responses as a bonus.grep -rn "InvalidAuthenticityToken" app lib spec testand rename toInvalidCrossOriginRequest. 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.
Comments
No comments yet. Be the first.