You add Google login to your app in an afternoon. Works great in testing. You ship it. Three weeks later you have users with duplicate accounts, some people can't find their data, and someone emailed you saying they lost their entire history. Welcome to social auth — where the happy path is easy and everything else is a disaster waiting to happen.
We've built auth flows into probably a dozen different projects at this point, and the same problems keep biting us. Not because the OAuth spec is hard — it's not. But because user behavior doesn't follow the happy path. People forget how they signed up. They use work email with Google, then try logging in with password. They change their email address. They revoke app access and wonder why they can't get back in. Let's go through the real edge cases and how to handle them.
The Duplicate Account Problem
This is the most common one. A user signs up with their email and password. Six months later, they click 'Continue with Google' because that button is right there and it's faster. Google hands you back their email address. Your code sees a new OAuth account, creates a new user, and now you have two separate records for the same human. All their data is in account #1 and they're now logged into account #2.
The fix sounds obvious: check if the email already exists before creating a new user. But it's not quite that simple. You need to decide what to do when you find that match.
// When handling the OAuth callback
async function handleOAuthCallback(profile: OAuthProfile) {
const { email, provider, providerId } = profile;
// First, check if there's already an OAuth account for this provider
const existingOAuthAccount = await db.query.oauthAccounts.findFirst({
where: and(
eq(oauthAccounts.provider, provider),
eq(oauthAccounts.providerAccountId, providerId)
),
with: { user: true },
});
if (existingOAuthAccount) {
// Normal login — this user has logged in with this provider before
return existingOAuthAccount.user;
}
// Check if a user with this email exists (signed up differently)
const existingUserByEmail = await db.query.users.findFirst({
where: eq(users.email, email),
});
if (existingUserByEmail) {
// Link the OAuth account to the existing user instead of creating a new one
await db.insert(oauthAccounts).values({
userId: existingUserByEmail.id,
provider,
providerAccountId: providerId,
});
return existingUserByEmail;
}
// Truly new user — create the account
const [newUser] = await db.insert(users).values({ email }).returning();
await db.insert(oauthAccounts).values({
userId: newUser.id,
provider,
providerAccountId: providerId,
});
return newUser;
}The key is separating your users table from your oauth_accounts table. One user can have many OAuth accounts linked to them. Don't store provider info directly on the user row — that's what backs you into a corner.
Should You Auto-Link or Ask First?
Auto-linking (what the code above does) is convenient but has a security implication: you're trusting that the OAuth provider verified the email. Most of the major ones do — Google and GitHub both verify email addresses before you can use them. But not every provider does, and this matters.
Never auto-link accounts based on email if the OAuth provider doesn't guarantee the email is verified. An attacker could create an OAuth account with your user's email at a sketchy provider and gain access to their account.
For the providers where you're not 100% sure about email verification, the safer pattern is to show the user a 'We found an existing account with this email' screen and ask them to confirm. Make them log into their original account first, then link the OAuth provider from settings. It's more friction, but it's secure.
// Check if the provider verifies email before auto-linking
const TRUSTED_PROVIDERS_WITH_VERIFIED_EMAIL = ['google', 'github', 'microsoft'];
async function handleOAuthCallback(profile: OAuthProfile) {
const { email, emailVerified, provider, providerId } = profile;
const existingUserByEmail = await db.query.users.findFirst({
where: eq(users.email, email),
});
if (existingUserByEmail) {
const canAutoLink =
TRUSTED_PROVIDERS_WITH_VERIFIED_EMAIL.includes(provider) &&
emailVerified;
if (!canAutoLink) {
// Store pending link in session, redirect to confirmation page
return { action: 'require_confirmation', existingUserId: existingUserByEmail.id };
}
// Safe to auto-link
await linkOAuthAccount(existingUserByEmail.id, provider, providerId);
return existingUserByEmail;
}
return createUserWithOAuth(email, provider, providerId);
}The 'I Forgot How I Signed Up' Problem
Your users will forget. They always do. Someone signed up with email/password three months ago, comes back, clicks Google, and you handle it correctly by linking. Great. But what about the reverse? They signed up with Google, now they want to set a password because they're switching to a new Google account.
You need to handle the case where a user has OAuth accounts but no password. If you have a 'Forgot password' flow, it needs to work for these users too — but the email they receive needs to set a password for the first time, not reset one.
// In your forgot password handler
async function handleForgotPassword(email: string) {
const user = await db.query.users.findFirst({
where: eq(users.email, email),
with: { oauthAccounts: true },
});
if (!user) {
// Don't reveal if the email exists — always return success
return { success: true };
}
if (!user.passwordHash && user.oauthAccounts.length > 0) {
// They signed up with OAuth — send a different email
const providers = user.oauthAccounts.map(a => a.provider).join(', ');
await sendEmail({
to: email,
subject: 'Set your password',
// Tell them HOW they signed up, give them option to set a password
template: 'oauth-user-set-password',
data: { providers, setPasswordToken: await generateToken(user.id) },
});
return { success: true };
}
// Normal password reset flow
await sendPasswordResetEmail(user);
return { success: true };
}The email copy matters here. Don't just say 'you don't have a password set.' Tell them 'You signed up with Google. Click here to also set a password, or just keep using Google to log in.' Users understand that.
What Happens When They Revoke Access
OAuth gives users the ability to revoke your app's access from the provider's side. They go into their Google account settings, see 'Apps with access', and remove your app. Now their OAuth tokens are invalid, but their account in your database still exists.
This is actually fine on the next login — they'll just go through the OAuth flow again and get new tokens. The provider will ask them to authorize again. But it does cause problems if you're storing and using access tokens for anything (like calling Google APIs on their behalf). Those tokens are now dead and you need to handle that error gracefully.
- If you're only using OAuth for identity (not calling provider APIs), revocation is mostly a non-issue
- If you're using access tokens to call provider APIs, catch 401 errors and re-initiate the OAuth flow
- Store refresh tokens if the provider gives them — they let you get new access tokens without user interaction
- Log 'account_disconnected' events so users can see if someone else revoked access to their account
Email Changes and Provider Mismatches
Here's one that took us an embarrassingly long time to handle properly. A user signs up with Google, using their personal Gmail. They link GitHub later. Then they change their Google email (or Google changes how they present the email — this actually happens with Google Workspace accounts). Suddenly the email coming from Google doesn't match what's in your database.
This is why you should store the provider's stable user ID (the `sub` field in OIDC, or the user ID from GitHub's API) and match on that — not the email. Email is a secondary identifier you show the user, not the one you use for lookups.
// oauth_accounts table structure — this is the right shape
export const oauthAccounts = pgTable('oauth_accounts', {
id: text('id').primaryKey().$defaultFn(() => createId()),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
provider: text('provider').notNull(), // 'google', 'github', etc.
// This is the stable ID from the provider — use this for lookups
providerAccountId: text('provider_account_id').notNull(),
// Store the email from the provider but don't rely on it for identity
providerEmail: text('provider_email'),
// Store tokens if you need to call provider APIs
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => ({
// Ensure one account per provider per user
uniqueProviderAccount: unique().on(table.provider, table.providerAccountId),
}));
// Always look up by providerAccountId, not by email
const account = await db.query.oauthAccounts.findFirst({
where: and(
eq(oauthAccounts.provider, 'google'),
eq(oauthAccounts.providerAccountId, googleProfile.sub) // 'sub' is stable
),
});Unlinking Accounts and the Last Login Method Problem
Once you let users link multiple OAuth providers, you have to let them unlink them too. This is where things get messy. What if they unlink their only login method and have no password set? They've just locked themselves out of their account.
You need to validate before unlinking:
async function unlinkOAuthAccount(userId: string, provider: string) {
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
with: { oauthAccounts: true },
});
if (!user) throw new Error('User not found');
const hasPassword = Boolean(user.passwordHash);
const linkedAccounts = user.oauthAccounts;
const accountToRemove = linkedAccounts.find(a => a.provider === provider);
if (!accountToRemove) {
throw new Error(`No ${provider} account linked`);
}
// Will they still be able to log in after this?
const remainingAccounts = linkedAccounts.filter(a => a.provider !== provider);
const willBeLockedOut = !hasPassword && remainingAccounts.length === 0;
if (willBeLockedOut) {
throw new Error(
'Cannot unlink your only login method. Set a password first, or link another provider.'
);
}
await db
.delete(oauthAccounts)
.where(
and(
eq(oauthAccounts.userId, userId),
eq(oauthAccounts.provider, provider)
)
);
return { success: true };
}The error message here is important. Don't just say 'can't unlink' — tell them exactly what to do: set a password, then come back and unlink. Users follow instructions when you're specific.
The CSRF State Parameter (Don't Skip This)
Every OAuth flow should include a `state` parameter — a random value you generate before redirecting to the provider, store in the session, and verify when you get the callback. This prevents CSRF attacks where a malicious site tricks a user's browser into completing an OAuth flow for an account they don't own.
Libraries like Auth.js handle this for you. If you're rolling your own, don't skip it. We've seen production apps in the wild without state validation — please don't be those apps.
// Before redirecting to OAuth provider
async function initiateOAuth(provider: string) {
const state = crypto.randomUUID();
const codeVerifier = generateCodeVerifier(); // for PKCE
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store in session/cookie — needs to survive the redirect
cookies().set('oauth_state', state, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 10 // 10 minutes is plenty
});
cookies().set('oauth_code_verifier', codeVerifier, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 10
});
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID!,
redirect_uri: `${process.env.APP_URL}/auth/callback/google`,
response_type: 'code',
scope: 'openid email profile',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
}
// In the callback
async function handleCallback(searchParams: URLSearchParams) {
const returnedState = searchParams.get('state');
const storedState = cookies().get('oauth_state')?.value;
if (!returnedState || returnedState !== storedState) {
throw new Error('Invalid state — possible CSRF attack');
}
// Clean up
cookies().delete('oauth_state');
// ... rest of callback handling
}A Few More Edge Cases Worth Handling
- Provider returns no email: Some GitHub users have private emails. You'll get null back. Either block them and explain they need to make their email public, or ask them to enter an email manually after OAuth
- Account suspended at provider: The OAuth flow still completes, but the user might have a bad account. Not your problem to police, but don't assume a successful OAuth means a trustworthy user
- Callback URL mismatch: Your redirect_uri in the request must exactly match what you registered in the provider's console. Include/exclude trailing slashes consistently. This burns hours
- Session fixation: After OAuth completes and you log the user in, regenerate the session ID. Don't keep the pre-auth session
- Scope changes: If you add new OAuth scopes later, existing users have only granted the old scopes. Handle 403s from provider APIs gracefully and re-initiate OAuth when needed
If this all sounds like a lot — it is. Social auth is one of those things where the implementation is genuinely complex if you want to handle it properly. This is part of why our peal.dev templates ship with all of this pre-wired: account linking, the duplicate email detection, the unlink validation. We've already debugged the 2am 'why do I have two accounts' support emails so you don't have to.
The happy path for OAuth takes an afternoon. The edge cases take a week. Budget accordingly.
If you take nothing else from this: separate your users table from your oauth_accounts table, always match on the provider's stable user ID (not email), and validate before letting users remove their last login method. Those three things alone will save you from 90% of the pain. The rest is details — important details, but details you can add incrementally as your user base grows and the weird edge cases start appearing in your support inbox.
