Can't verify CSRF token authenticity
The error reads as a problem with a token, and about half the time the token is fine. Three different failures print three different messages, two of them never mention a token, and the one cause that trips people most often is a cookie going missing somewhere else entirely.
Everything below was measured on 2026-10-03 against actionpack 8.1.4, Rack 3.2.7 and Ruby 4.0.5,
in an application generated by rails new --minimal, with ActionController::Base.allow_forgery_protection
turned back on for the test because the generated test environment turns it off.
The predicate, which is the whole article
ActionController::RequestForgeryProtection#verified_request? is four terms long:
def verified_request? # :doc:
request.get? || request.head? || !protect_against_forgery? ||
(valid_request_origin? && any_authenticity_token_valid?)
end
Read it as a list of ways to pass. A GET passes. A HEAD passes. A controller with protection off passes. Everything else has to satisfy both halves of the last term, and the two halves fail with different messages.
This is why "my GET works and my POST does not" is not a clue about anything. GET was never checked.
Three messages, and only one is about the token
unverified_request_warning_message picks between the first two:
def unverified_request_warning_message
if valid_request_origin?
"Can't verify CSRF token authenticity."
else
"HTTP Origin header (#{request.origin}) didn't match request.base_url (#{request.base_url})"
end
end
So the message everybody searches for, Can't verify CSRF token authenticity., is specifically
the case where the origin check passed and the token did not. If you are seeing it, the
request came from where it claims to have come from, and the token is missing, stale, or bound to
a different session.
The second message is the opposite situation, and it names both values it compared, which makes it the easiest of the three to act on.
The third is raised from a different place, request_forgery_protection.rb:638, and only when the
origin header is the literal string null:
raise InvalidAuthenticityToken, NULL_ORIGIN_MESSAGE if request.origin == "null"
NULL_ORIGIN_MESSAGE is six lines, declared private_constant, and it is the only one of the
three that tells you what to do: it blames a no-referrer Referrer-Policy and suggests
same-origin or strict-origin instead, with
Rails.application.config.action_controller.forgery_protection_origin_check as the escape hatch.
Worth knowing that the generated default is not the dangerous one. A fresh Rails 8.1 app answers
strict-origin-when-cross-origin on Referrer-Policy, and a browser sends a real origin under
that. You get the null origin message after somebody has tightened the policy.
The cause that is not about the token
This is the one worth internalising, because the error message points away from it.
The token is verified against the session. any_authenticity_token_valid? ends up in
valid_authenticity_token?(session, token), and a token that does not match the session's
_csrf_token is as good as absent. That key is real and visible: after rendering a form, the
session holds exactly ["_csrf_token", "session_id"].
Measured, in an integration test: fetch the form, pull the token out of the HTML, delete the session cookie, POST the token anyway.
get "/csrf/new"
token = response.body[/name="authenticity_token" value="([^"]+)"/, 1]
cookies.delete("_lab_session")
post "/csrf", params: { authenticity_token: token }
# => 422
The token is a correct, freshly minted, 86 character token. It fails because there is no session to check it against.
Everything that can quietly drop a session cookie therefore produces this error:
- A form page served from a cache or a CDN, so the token in the HTML belongs to somebody else's session, or to no session.
- A POST to a different host than the one that served the form.
www.example.comandexample.comare two cookie scopes unless the cookie was written with adomain. - A cross site POST at all, since
samesite=laxmeans the browser withholds the session cookie on a cross site POST. The token travels in the body, the session does not travel at all. Where thatlaxcomes from, and the rest of the session cookie's attributes, is in Setting a cookie in Rails. reset_sessionbetween rendering the form and submitting it, which is what a sign in does.
The last one is the common shape of "it works, except right after logging in".
Two forms, two tokens
per_form_csrf_tokens defaults to true, and it is not widely known that this means what it says.
Each form_with on a page gets a token scoped to that form's action and method.
Measured on a page carrying two forms, one posting to /csrf and one to /other:
| Check | Result |
|---|---|
| Forms on the page | 2 |
| The two tokens are identical | false |
Form one's token, posted to /csrf |
200 |
Form one's token, posted to /other |
422 |
The consequence is practical: a JavaScript client that scrapes an authenticity_token out of the
first form it finds and reuses it for a different endpoint gets a 422 that looks random. The
token from csrf_meta_tags is the unscoped one and is the right thing for that job.
The status code, and a constant that moved
The exception is not an exception by the time anybody sees it. ActionDispatch::ExceptionWrapper
carries the mapping:
"ActionController::InvalidAuthenticityToken" => ActionDispatch::Constants::UNPROCESSABLE_CONTENT,
and action_dispatch/constants.rb resolves that at load time against the Rack version:
if Gem::Version.new(Rack::RELEASE) < Gem::Version.new("3.1")
UNPROCESSABLE_CONTENT = :unprocessable_entity
else
UNPROCESSABLE_CONTENT = :unprocessable_content
end
Both are 422. The symbol is what differs, which matters only if you have
rescue_from/assert_response code written against the old name, and then it matters a lot and
fails in a way that reads as unrelated.
The dead end: you cannot catch it in a test
The obvious test is the one that does not work:
assert_raises(ActionController::InvalidAuthenticityToken) { post "/csrf" }
That fails. Nothing is raised as far as the test is concerned, because
ActionDispatch::ShowExceptions has already turned it into a response. The next guess does not
work either: request.env["action_dispatch.exception"] is nil, so you cannot even read the
exception back off the request.
What works is asserting the response.
post "/csrf"
assert_response :unprocessable_content # 422
If you genuinely need the exception object, turn the middleware off for that request with
Rails.application.env_config["action_dispatch.show_exceptions"] = :none. For an integration test
that only wants to prove protection is on, the status is the better assertion anyway.
What null_session actually does
protect_from_forgery with: :null_session is widely described as "ignore CSRF", and that is not
what it does. Measured:
POST /null_session -> 200
body: "session empty? true"
The request is served. The session inside the action is empty, checked with
session.to_hash.except('session_id').empty?. So the action runs as an anonymous visitor, and
anything that reads session[:user_id] behaves as if nobody is signed in.
That is the right behaviour for a public endpoint and a trap for a mixed one: the request succeeds,
no error is logged, and the action does the wrong thing silently. with: :exception is noisy and
noisy is better.
The third strategy, :reset_session, calls @controller.reset_session, which destroys the
session rather than hiding it for one request.
If this is happening on an API
It is not happening on an ActionController::API.
ActionController::API.respond_to?(:protect_from_forgery) # => false
Measured: a POST to an ActionController::API controller with no token at all answers 200. The
module is not included, the class method does not exist, and
config.action_controller.default_protect_from_forgery only reaches ActionController::Base.
So an InvalidAuthenticityToken on a JSON endpoint means that endpoint's controller inherits from
ActionController::Base somewhere up the chain, usually from an ApplicationController shared
with the HTML side. The fix is a separate base class, not a skip_before_action.
What this page does not cover
No browser was involved. Every request above came from ActionDispatch::IntegrationTest, which
means real browser behaviour around SameSite was never observed, only reasoned about from the
cookie attributes the server sent. The 422 responses were read as status codes, not as what a
Turbo form does when it receives one.
Rails 8.2's Sec-Fetch-Site based protection is a different mechanism and is covered separately in
What Rails 8.2 does with Sec-Fetch-Site. Nothing here
touches it, and the numbers above are 8.1 numbers.
Also out of scope: protect_from_forgery prepend: true and the ordering problems it solves,
sharing a session across subdomains with config.session_store ... domain: :all, SameSite=None
and the secure flag it requires, token rotation across multiple application servers with
different secret_key_base, and form_authenticity_token(form_options:), which is the supported
way to mint a per form token by hand and which was read in the source but never run.
Comments
No comments yet. Be the first.