A user signs up with their email and password. Six months later they click "Sign in with Google" using the same email address. What should happen? Creating a second account leaves them with two separate identities and no data continuity. Silently merging them without verification gives anyone who can trigger a Google OAuth flow for that email address access to the original account. Account linking is one of the most security-sensitive flows in an auth system, and the wrong approach here is a meaningful vulnerability.
The verified email requirement
The only safe foundation for automatic account linking is a verified email address. If an OAuth provider returns an email and asserts that it is verified (email_verified: true in the ID token), you can reasonably link the incoming social identity to an existing account registered with that email. If the email is unverified in the OAuth response, you must not link automatically.
Major providers behave differently. Google always sets email_verified: true for Gmail addresses and for Google Workspace addresses that the admin controls. GitHub does not expose email_verified in the standard OIDC flow — you must call the /user/emails API and check the verified field per address. Apple's id_token includes email_verified but note that Apple's "Hide My Email" relay addresses are considered verified even though they are not the user's real address.
// Handle OAuth callback — link or register decision
async function handleOAuthCallback(provider, idToken, accessToken) {
const profile = await verifyAndDecodeIdToken(provider, idToken);
// Never link on unverified email
if (!profile.email_verified) {
return {
action: 'registration_required',
reason: 'email_not_verified_by_provider',
};
}
// Check if this social identity is already linked
const existingLink = await db.oauthLinks.findOne({
provider,
providerUserId: profile.sub,
});
if (existingLink) {
// Known identity — straightforward login
const user = await db.users.findById(existingLink.userId);
return { action: 'login', user };
}
// New social identity — check if email exists in our system
const existingUser = await db.users.findByEmail(profile.email);
if (existingUser) {
// Email collision — needs link confirmation
return {
action: 'link_confirmation_required',
existingUserId: existingUser.id,
provider,
providerUserId: profile.sub,
email: profile.email,
};
}
// Net new user — register
return { action: 'register', profile };
}
Link-on-login vs explicit link
There are two interaction patterns for handling the collision case where an OAuth email matches an existing account.
Link-on-login interrupts the social login flow and asks the user to confirm they want to connect their Google account to their existing account. Typically this means presenting a prompt: "We found an existing account with this email. Sign in with your password to link your Google account." The user enters their password, proving they own the existing account, and the link is created. Future logins can use either method.
Explicit link is initiated by an already-authenticated user from their account settings page. There is no ambiguity about ownership — the user is already logged in and deliberately connecting a new login method. This is simpler and safer. Many teams implement explicit link and make the link-on-login flow an email-confirmation path instead of a password confirmation path, since not all existing accounts necessarily have a password (they might have signed up via a different OAuth provider).
// Explicit link: user is authenticated, adds a new OAuth method
app.post('/account/link/:provider', requireAuth, async (req, res) => {
const { provider } = req.params;
const userId = req.user.id;
// Store pending link intent in session before redirect
req.session.pendingLink = { userId, provider, returnTo: req.headers.referer };
const authUrl = buildOAuthUrl(provider, {
redirectUri: `${BASE_URL}/account/link/${provider}/callback`,
state: req.session.id,
});
res.redirect(authUrl);
});
app.get('/account/link/:provider/callback', requireAuth, async (req, res) => {
const { provider } = req.params;
const pending = req.session.pendingLink;
if (!pending || pending.userId !== req.user.id) {
return res.status(400).json({ error: 'invalid_link_state' });
}
const profile = await exchangeCodeForProfile(provider, req.query.code);
// Check this social identity is not already linked to a different account
const existingLink = await db.oauthLinks.findOne({
provider,
providerUserId: profile.sub,
});
if (existingLink && existingLink.userId !== req.user.id) {
return res.redirect('/account/settings?error=identity_already_linked_to_another_account');
}
await db.oauthLinks.upsert({
userId: req.user.id,
provider,
providerUserId: profile.sub,
linkedAt: new Date(),
});
delete req.session.pendingLink;
res.redirect('/account/settings?success=provider_linked');
});
Collision handling and the unlink flow
The collision that needs the most careful handling is when a social identity is already linked to account A, but the currently authenticated user is account B trying to link the same identity. This can happen when someone has multiple accounts (a personal and a work account, for example). The safe response is to show an error, not to silently re-link — you should never unlink an identity from one account and attach it to another without explicit confirmation from both sides.
The unlink flow removes a linked identity from an account. The safety constraint: an account must always retain at least one authentication method. Unlinking the last login method would lock the user out permanently.
// Unlink a social provider
app.delete('/account/link/:provider', requireAuth, async (req, res) => {
const { provider } = req.params;
const userId = req.user.id;
// Count remaining authentication methods
const methods = await db.oauthLinks.count({ userId });
const hasPassword = await db.users.hasPasswordSet(userId);
const totalMethods = methods + (hasPassword ? 1 : 0);
if (totalMethods <= 1) {
return res.status(400).json({
error: 'cannot_unlink_last_auth_method',
message: 'Set a password before removing your last login method.',
});
}
await db.oauthLinks.delete({ userId, provider });
res.json({ success: true });
});
Security implications
Automatic linking based on unverified email is an account takeover vector. An attacker who can register a Google account with the email victim@gmail.com — which is not possible for Gmail, but is possible for custom OAuth providers with lax email verification — could link their social identity to the victim's account. Always verify email_verified before linking.
Email change on a linked account also needs care. If a user changes their email address on a connected OAuth provider, subsequent logins use the old sub (provider user ID) to look up the link, so the email change does not cause issues. But if you are also storing the provider's email for display purposes, update it from the ID token on each successful login to keep it fresh.