LaunchKit
← All posts
· 8 min read · by The LaunchKit team · 6 views

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.

A POST with forgery protection on passes two independent gates, and each one fails with its own message. The origin gate fails two different ways: a literal null origin raises the six line message that blames the referrer policy, and any other mismatch prints the origin it saw next to the base_url it expected. Only after the origin gate passes is the token looked at, and that is the one failure that prints Can't verify CSRF token authenticity. A GET or a HEAD reaches neither gate.

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.com and example.com are two cookie scopes unless the cookie was written with a domain.
  • A cross site POST at all, since samesite=lax means 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 that lax comes from, and the rest of the session cookie's attributes, is in Setting a cookie in Rails.
  • reset_session between 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.

#rails #security #debugging

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.