OAuth account linking: the three cases, and the one that bites
September 06, 2026
OmniAuth validates the OAuth exchange and hands you a payload. Turning that payload 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 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
OauthIdentity.find_by(provider:,uid:)
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 uid is 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
This 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.
An OAuth user never types a password, and has_secure_password validates 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_at is 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, in one place
All three branches converge on the same small method:
claimed_at records 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.
OmniAuth validates the OAuth exchange and hands you a payload. Turning that payload 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 identity record
Before 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
This 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: nobody at all
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, 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.