LaunchKit

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:

return request_call if on_request_path? &&
  OmniAuth.config.allowed_request_methods.include?(request.request_method.downcase.to_sym)

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.

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.

More on Social sign-in with OmniAuth

← All Social sign-in with OmniAuth articles