LaunchKit

Social sign-in with OmniAuth

Ten providers behind one registry, plus the three account-linking cases nobody warns you about.

"Sign in with Google" looks like a morning's work. The OAuth exchange genuinely is, because OmniAuth does it for you. What takes the time is everything that happens after the provider hands you back an email address.

What OmniAuth actually gives you

Two phases. The request phase at /auth/:provider redirects the visitor to the provider. The callback phase at /auth/:provider/callback receives them back, and by the time your controller runs, OmniAuth has already validated the exchange and populated request.env["omniauth.auth"].

That payload is all you get, and all you need. Everything below is about what you do with it.

If you have not wired one up before, adding Google sign-in from scratch is the whole path, from an empty Google Cloud console to a working button.

One security note first, because it is easy to miss and hard to notice: the request phase must be a POST. A <a href="/auth/google_oauth2"> link is a login CSRF waiting to happen. The omniauth-rails_csrf_protection gem forces the POST and checks the authenticity token. Plenty of tutorials still show the link version, which works, which is the problem.

Mounting providers by configuration

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, and is silently absent otherwise. That registry is what keeps ten providers from becoming ten code paths. That is what lets a fresh install boot with no keys at all and still show a working sign-in page. In test, every provider whose gem is present is mounted even with blank credentials, because a route has to exist for a request spec to hit it, which is the first thing testing OmniAuth without touching the provider has to get right.

Apple is the provider that tests that arrangement hardest, because it stores no client secret to fill in at all: Sign in with Apple takes a Services ID and a signing key instead, and the strategy mints a fresh JWT secret on every request.

The account-linking cases, and the one that bites

Once the provider says "this is alice@example.com, and I have verified it", you have to decide what that means. There are three cases and they are genuinely different:

  1. The identity is already linked. Sign that user in. The lookup is on the provider's uid, never the email, or a user who changes their Google address silently loses their account. Account linking takes the three cases apart one by one.
  2. No identity, but an account exists with that email. Link them. This is safe because the provider verified the address, and unsafe the moment that stops being true.
  3. Neither. Create the account, pre-confirmed, with a strong random password the user can replace later through the forgot-password flow.

Case 2 is where products get this wrong in both directions. Refuse to link and you create a duplicate account for someone who already has one. Link on an unverified email and you have handed an attacker anyone's account.

And there is a fourth case that only exists because this product sells before it signs anyone up: a buyer claiming a paid account with a social login must be attached to the account holding the payment, not to whatever account the email happens to match.

One OmniAuth callback carrying a verified email address splits into three cases, each with a different ending. When the provider uid is already linked, that user is signed in. When there is no identity but an account already holds the email, the identity is linked to it, which creates a duplicate account if you refuse and hands over someone else's account if the email was never verified. When there is neither, the account is created pre-confirmed with a strong random password.

The plain Rails pieces that carry social sign-in

Very little of this is OmniAuth's problem. The pieces that carry the weight are ordinary Rails: a uniqueness: { scope: :provider } validation on the identity so two providers can share a uid, find_or_initialize_by for the create-or-link branch, has_secure_password insisting on a password even for a user who will never type one, and the session concern that turns the resolved user into a signed cookie.

The articles below take each of those in turn.

One of them is not a Rails piece at all but a gate in front of the whole flow. OmniAuth's CSRF protection covers the request phase becoming POST-only in OmniAuth 2.0: why the sign-in link turns into a routing failure, which of the two defences ships in OmniAuth itself, and the login-CSRF attack the change exists to close.

The other error that stops the flow before your code runs is a string comparison. redirect_uri_mismatch works through how OmniAuth builds the callback URL it sends, why that value is derived from the incoming request rather than from your configuration, and the four ways the string quietly differs from the one registered with the provider.

Articles on this topic