LaunchKit

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:

def config = Auth::OauthProvider.find_by_strategy(auth.provider)

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.

Mount what is configured, not what is coded

providers = Rails.env.test? ? Auth::OauthProvider.all : Auth::OauthProvider.configured
providers.each { |oauth_provider| provider(*oauth_provider.omniauth_args) }

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?

return connect_to_current_user if authenticated?

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:

case Auth::OmniauthLink.new(Current.user, omniauth_payload).call
when :linked   then # ...
when :already  then # ...
when :taken    then # ...
end

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.

More on Social sign-in with OmniAuth

← All Social sign-in with OmniAuth articles