OAuth account linking: the three cases, and the one that bites
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
- Running ten OAuth providers without ten code paths
- Add Google sign-in to Rails 8, from scratch
OmniAuth validates the OAuth exchange and hands you a payload. If you have not wired one up yet, the Google walkthrough gets you to that payload from an empty Google Cloud console. Turning it into a signed-in user is one method, and it has three branches. Getting the middle one wrong is either a duplicate-account problem or an account-takeover problem, depending on which way you get it wrong.
The
OauthIdentityrecord, and what it is unique onBefore the branches, the model they hang off:
The uniqueness is scoped to the provider, not global. Two different providers can perfectly well hand you the same uid string, and a global uniqueness constraint would reject the second one with a validation error that makes no sense to anybody reading it. A user has many identities, which is what makes "connect my GitHub as well" possible later.
Case 1: the identity already exists
A returning user. The lookup is on the provider's stable
uid, never on the email.This is the single most common mistake in a home-grown implementation, and it is silent until it is not: email addresses change. A user who updates their Google address and comes back finds themselves in a brand new account with none of their data, and there is no error anywhere to tell you it happened. The
uidis the provider's permanent identifier for that person and it is the only thing you should key on.Case 2: no identity, but the email matches an account
Case 2 is the branch worth thinking about. Somebody signed up with a password months ago. Today they click "Sign in with Google", and the address matches.
Two wrong answers are available.
Refuse and create a second account. They now have two, one holding their data and one they can actually get into. Every support conversation from here on starts with "I can't see my things".
Link on whatever email the provider sends. If the provider has not verified that the address belongs to the person signing in, anyone who can register that address at that provider can now sign in as your user. This is not theoretical; it is how several real takeovers have worked.
The right answer is to link, because the provider verified the address. That verification is not a detail, it is the entire justification for the branch. A provider that does not verify emails cannot be trusted here, and if you add one you have to special-case it rather than letting it fall through this code path.
Case 3: no identity and no account, so create one
Create the account. Two lines deserve attention:
An OAuth user never types a password, and
has_secure_passwordvalidates presence on creation, so they need one regardless. Beyond satisfying the validation, it means the forgot-password path remains open: someone who loses access to their Google account is not locked out of yours forever. The generated value is deliberately strong:The fixed suffix is there so the value satisfies the app's own password complexity validator. It is not a secret anyone needs to know, because nobody will ever type it.
confirmed_atis set immediately because the provider already proved the address. Sending your own confirmation email would ask the user to prove something that is already proven, and the most likely outcome is that they do not bother.The fourth case, if you sell before you sign up
This codebase lets someone pay before they have an account. When that buyer comes back and claims their purchase with "Sign in with Google" instead of a password, the identity must attach to the account holding the payment:
The id comes from the signed session, not from the URL, so possession of the success page link is not enough to claim someone else's purchase.
Fall through to the ordinary email match instead and the buyer signs into some older account of theirs, while the Stripe customer id and the subscription sit on a different row entirely. Nothing raises. It only happens to people who have just given you money, and you find out from a support email.
Claiming an account, in one place
All three branches converge on the same small method:
claimed_atrecords that the user now owns their login, by password or by provider. The||=matters: claiming twice should not move the date. And saving before attaching the identity is not stylistic, it is what guarantees the identity has a persisted user to belong to.