OmniAuth's CSRF protection: why the request phase is a POST
September 13, 2026
A sign-in button that worked for years turns into a 404 after an OmniAuth upgrade, and nothing in
the error mentions OmniAuth. The credentials are fine, the route file is unchanged, the provider
dashboard is untouched. What changed is the HTTP verb.
The social sign-in hub walks the OAuth handshake
end to end. This is the one step of it that is not OAuth at all: the request phase belongs to your
application, and since OmniAuth 2.0 it is defended like any other state-changing action.
The line that decides it
OmniAuth's middleware makes the call in a single condition:
Two things have to be true. The path has to be the request path, and the method has to be allowed.
Since 2.0 the allowed list holds :post and nothing else.
Read what happens when the second test fails, because it explains the confusing part. There is no
fail!, no redirect to a failure endpoint, no message. The condition is simply false, the next two
conditions are false as well, and the middleware ends at @app.call(env). The request is handed to
your Rails application, which has no route for /auth/google_oauth2, and Rails answers the way it
answers any unrouted path.
So the symptom is a routing failure on a URL you can see in your own layout, which is why the first
hour of debugging this usually goes into config/routes.rb, where the answer is not.
Two defences, and only one of them is OmniAuth's
Restricting the verb is the smaller half of OmniAuth CSRF defence. A POST is harder to forge than a
GET because it cannot be an image tag or a link, but a hidden auto-submitting form is not hard to
write. The verb alone does not stop a determined forgery.
The check that does is the Rails authenticity token, and OmniAuth does not perform it. That is the
job of a separate gem, the OmniAuth rails_csrf_protection gem:
gem"omniauth-rails_csrf_protection"
It wires ActionController::RequestForgeryProtection into the request phase, so the POST has to
carry a valid token from a session belonging to the person making it. Install the gem and the two
defences compose: the request has to be a POST and it has to prove it came from a page your
application rendered to this visitor.
Set allowed_request_methods without installing the gem and you have the weaker half on its own.
The wiring is worth being explicit about even though POST is already the default:
# Be explicit (this is also the OmniAuth 2 default): only POST may start the auth flow.OmniAuth.config.allowed_request_methods=[:post]
A line like that is documentation as much as configuration. It tells the next reader that the verb
restriction is deliberate and not a default nobody looked at.
Which means the button is a form
A link cannot carry an authenticity token. So the entry point changes shape:
<%# Before: a link, which is now a 404 %><%=link_to"Sign in with Google","/auth/google_oauth2"%><%# After: a form, which carries the token %><%=button_to"Sign in with Google","/auth/google_oauth2"%>
button_to renders a form with a hidden authenticity_token field and a submit button. That is the
entire migration, and it is also the step that gets missed, because sign-in entry points have a
habit of existing in more places than a project remembers: a navbar partial, an empty state, a
paywall, a marketing page, the one inside a mailer that has to stay a link and therefore has to
point at a page rather than at the request path.
Styling the result is the usual objection. A button_to is a form wrapping a button, so it takes a
class: like anything else, and the form element itself can be given form_class: when its display
mode gets in the way of a layout.
The attack this closes
The advisory is CVE-2015-9284, and its title is exactly the subject: CSRF vulnerability in
OmniAuth's request phase. OmniAuth considers it significant enough to keep a wiki page for it,
Resolving CVE-2015-9284, which
the library links from its own source.
The attack is login CSRF, and it runs the opposite way round to the CSRF people picture. Nothing is
done to the victim's account. The forged request signs the victim into an account the attacker
controls.
The consequence is quiet and that is the point. The victim keeps using what looks like your product,
unaware they are inside someone else's account, and everything they do afterwards accumulates there:
a document they upload, an address they save, a card they add, a search history that was meant to be
private. The attacker signs into their own account later and reads it.
That is why the request phase counts as state-changing even though it changes nothing in your
database yet. It changes who the browser is.
OmniAuth will still let you turn it off, loudly
The OmniAuth allowed_request_methods setting is configuration, so :get can be put back. OmniAuth's
answer to that is to log a warning, and the warning is unusually direct for a library:
You are using GET as an allowed request method for OmniAuth. This may leave you open to CSRF
attacks. As of v2.0.0, OmniAuth by default allows only POST to its own routes.
It then prints four links, CVE-2015-9284 among them. There is a silence_get_warning flag for
anyone who would rather not hear it, which is a fair summary of the decision being made.
The legitimate reason to want GET is almost always a redirect: a provider, or a marketing email, or
an old bookmark, sending someone to /auth/<provider> directly. The answer is a route of your own
that renders a page with the form on it, rather than reopening the request phase to a verb it spent
a major version closing.
Where it sits in the rest of the flow
Only the request phase is affected. The callback, /auth/<provider>/callback, is still a GET,
because it is the provider's browser redirect coming back and the provider decides its shape. The
protection there is a different mechanism, the OAuth state parameter, which the strategy handles.
That split is worth holding on to, because it explains a test-suite surprise:
testing OmniAuth in Rails
relies on OmniAuth.config.test_mode, which short-circuits the handshake and hands a mock straight
to the callback. Test mode therefore never exercises the request phase, so a suite can stay
completely green while every sign-in button in the application is a 404 in production. The thing to
assert is that the entry point renders a form, which is a view concern rather than an OmniAuth one.
A sign-in button that worked for years turns into a 404 after an OmniAuth upgrade, and nothing in the error mentions OmniAuth. The credentials are fine, the route file is unchanged, the provider dashboard is untouched. What changed is the HTTP verb.
The social sign-in hub walks the OAuth handshake end to end. This is the one step of it that is not OAuth at all: the request phase belongs to your application, and since OmniAuth 2.0 it is defended like any other state-changing action.
The line that decides it
OmniAuth's middleware makes the call in a single condition:
Two things have to be true. The path has to be the request path, and the method has to be allowed. Since 2.0 the allowed list holds
:postand nothing else.Read what happens when the second test fails, because it explains the confusing part. There is no
fail!, no redirect to a failure endpoint, no message. The condition is simply false, the next two conditions are false as well, and the middleware ends at@app.call(env). The request is handed to your Rails application, which has no route for/auth/google_oauth2, and Rails answers the way it answers any unrouted path.So the symptom is a routing failure on a URL you can see in your own layout, which is why the first hour of debugging this usually goes into
config/routes.rb, where the answer is not.Two defences, and only one of them is OmniAuth's
Restricting the verb is the smaller half of OmniAuth CSRF defence. A POST is harder to forge than a GET because it cannot be an image tag or a link, but a hidden auto-submitting form is not hard to write. The verb alone does not stop a determined forgery.
The check that does is the Rails authenticity token, and OmniAuth does not perform it. That is the job of a separate gem, the OmniAuth rails_csrf_protection gem:
It wires
ActionController::RequestForgeryProtectioninto the request phase, so the POST has to carry a valid token from a session belonging to the person making it. Install the gem and the two defences compose: the request has to be a POST and it has to prove it came from a page your application rendered to this visitor.Set
allowed_request_methodswithout installing the gem and you have the weaker half on its own. The wiring is worth being explicit about even though POST is already the default:A line like that is documentation as much as configuration. It tells the next reader that the verb restriction is deliberate and not a default nobody looked at.
Which means the button is a form
A link cannot carry an authenticity token. So the entry point changes shape:
button_torenders a form with a hiddenauthenticity_tokenfield and a submit button. That is the entire migration, and it is also the step that gets missed, because sign-in entry points have a habit of existing in more places than a project remembers: a navbar partial, an empty state, a paywall, a marketing page, the one inside a mailer that has to stay a link and therefore has to point at a page rather than at the request path.Styling the result is the usual objection. A
button_tois a form wrapping a button, so it takes aclass:like anything else, and the form element itself can be givenform_class:when its display mode gets in the way of a layout.The attack this closes
The advisory is CVE-2015-9284, and its title is exactly the subject: CSRF vulnerability in OmniAuth's request phase. OmniAuth considers it significant enough to keep a wiki page for it, Resolving CVE-2015-9284, which the library links from its own source.
The attack is login CSRF, and it runs the opposite way round to the CSRF people picture. Nothing is done to the victim's account. The forged request signs the victim into an account the attacker controls.
The consequence is quiet and that is the point. The victim keeps using what looks like your product, unaware they are inside someone else's account, and everything they do afterwards accumulates there: a document they upload, an address they save, a card they add, a search history that was meant to be private. The attacker signs into their own account later and reads it.
That is why the request phase counts as state-changing even though it changes nothing in your database yet. It changes who the browser is.
OmniAuth will still let you turn it off, loudly
The OmniAuth allowed_request_methods setting is configuration, so
:getcan be put back. OmniAuth's answer to that is to log a warning, and the warning is unusually direct for a library:It then prints four links, CVE-2015-9284 among them. There is a
silence_get_warningflag for anyone who would rather not hear it, which is a fair summary of the decision being made.The legitimate reason to want GET is almost always a redirect: a provider, or a marketing email, or an old bookmark, sending someone to
/auth/<provider>directly. The answer is a route of your own that renders a page with the form on it, rather than reopening the request phase to a verb it spent a major version closing.Where it sits in the rest of the flow
Only the request phase is affected. The callback,
/auth/<provider>/callback, is still a GET, because it is the provider's browser redirect coming back and the provider decides its shape. The protection there is a different mechanism, the OAuthstateparameter, which the strategy handles.That split is worth holding on to, because it explains a test-suite surprise: testing OmniAuth in Rails relies on
OmniAuth.config.test_mode, which short-circuits the handshake and hands a mock straight to the callback. Test mode therefore never exercises the request phase, so a suite can stay completely green while every sign-in button in the application is a 404 in production. The thing to assert is that the entry point renders a form, which is a view concern rather than an OmniAuth one.Adding Google sign-in from scratch walks the full handshake with this already in place, and running ten OAuth providers without ten code paths is where the button becomes a loop over a provider registry, which is also the cheapest way to make sure no sign-in entry point is left as a link.