Running ten OAuth providers without ten code paths
September 06, 2026
More on Social sign-in with OmniAuth
- redirect_uri_mismatch: the OAuth callback URL contract
- OmniAuth's CSRF protection: why the request phase is a POST
- Sign in with Apple in Rails: the provider with no client secret
- Testing OmniAuth in Rails, without touching the provider
- OAuth account linking: the three cases, and the one that bites
- Add Google sign-in to Rails 8, from scratch
Adding the first OAuth provider is a morning, and the Google walkthrough is that morning end to end. Adding the second is where the copy-paste starts.
Google's strategy calls itself
google_oauth2. GitHub's calls itselfgithub. Each puts the user's email somewhere slightly different in the payload, each wants its own credential pair, and some are not configured at all in a given deployment. Left alone, that becomes a conditional per provider in every file that touches authentication.The login CSRF in the OmniAuth request phase
Before any of the structure, the thing that is easy to get wrong and hard to notice.
The OmniAuth request phase must be a POST. A plain
<a href="/auth/google_oauth2">link is a login CSRF: an attacker can cause a victim's browser to start an OAuth flow and, in the wrong circumstances, log them into the attacker's account. Theomniauth-rails_csrf_protectiongem enforces the POST and checks the authenticity token, and it is in this codebase for exactly that reason.A lot of tutorials still show the link version. It works, which is the problem.
One registry, one stable key
The fix for the rest is a registry that maps each provider's strategy name to a key of your own:
Everything downstream uses the stable key: the
providercolumn on the identity row, the connected accounts page, the buttons on the sign-in form. The strategy name stays where it belongs, at the edge, in the one place that translates it.The same idea covers the payload differences. Reading the email through the provider config rather than at a fixed path means an unusual provider overrides where its email lives, and nothing else in the app has to know that it did.
Without this,
auth.info.emailis scattered through your codebase and the first provider that puts it elsewhere forces you to find every occurrence.Mount what is configured, not what is coded
Outside test, a provider mounts exactly when its credentials are filled in. This matters more than it looks. It means a fresh clone with no keys at all boots and serves a working sign-in page, rather than raising on an unconfigured strategy or rendering a button that leads to a 500. For a codebase other people are going to clone, that is the difference between a good first five minutes and a bad one.
In test the rule inverts: every provider whose strategy gem is present is mounted, credentials or not, because a route has to exist for a request spec to exercise it. Two environments, two definitions of "available", one line.
There is a third case worth handling: a provider whose strategy gem was removed from the Gemfile. Skipping it rather than raising means removing a gem is a one-line change instead of a hunt through initializers.
Linking a second provider to an existing account
Once more than one provider exists, users will want more than one on the same account. That is a different flow from signing in, and the difference is a single question: is someone already signed in?
When they are, the identity attaches to the current user whatever email the provider returns. That is the exact opposite of the signed-out rule, and it is correct: the person has already proven who they are by holding a valid session. Insisting the emails match would block the very common case of a work GitHub account and a personal Google one.
When they are not signed in, the email match is the only evidence available, which is why the verified flag carries so much weight in that path.
The three outcomes of linking a provider
Linking has three outcomes, and they are genuinely different:
Linked is the happy path. Already means this identity is on this account, which is not an error and should not read like one; it usually means someone clicked twice. Taken means the identity belongs to a different account, and the only safe answer is to refuse, because silently moving an identity between accounts is an account takeover with extra steps.
Collapsing the last two into one message is how you end up telling a user their own GitHub account belongs to a stranger.