Running ten OAuth providers without ten code paths
September 06, 2026
Adding the first OAuth provider is a morning. Adding the second is where the copy-paste starts.
Google's strategy calls itself google_oauth2. GitHub's calls itself github. 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 CSRF trap, first
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. The omniauth-rails_csrf_protection gem
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 provider column 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.email is scattered through your codebase and the first provider that puts
it elsewhere forces you to find every occurrence.
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?
returnconnect_to_current_userifauthenticated?
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.
Say which of the three things happened
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.
Adding the first OAuth provider is a morning. 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 CSRF trap, first
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.
Say which of the three things happened
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.