Social Login End to End
'Sign in with Google' assembled from the pieces: OIDC, account linking, and your own session at the end.
Social login is not a separate scheme — it is OpenID Connect, wired into your own account system. This lesson is the joins: what you store, how you link accounts, and what happens when things collide.
The pipeline
- User clicks the provider button → Authorization Code + PKCE flow.
- You verify the ID token (signature,
iss,aud,exp,nonce). - You look up
(provider, sub)in anidentitiestable. - Found → log in. Not found → link or create.
- You issue your own session. The provider's tokens stay server-side.
The data model that avoids pain
Two tables, not one. users holds your account; identities holds (provider, provider_sub, user_id). One user can then have several identities — Google, GitHub, and a password — and adding a provider later is an insert, not a migration.
The linking decision
Alice signed up with a password. Later she clicks "Sign in with Google" and the email matches. Do you link?
- Only if
email_verifiedis true. Otherwise anyone can register that address at a lax provider and walk into her account. - Safest is to ask. "An account with this email exists — sign in with your password once to link Google." Slightly more friction, no takeover.
- Never link on an unverified email. Ever.
Practical details that bite
- Providers can omit the email. GitHub users can hide it; Apple's private relay gives you a proxy address. Handle a missing email rather than crashing.
- The email can change at the provider.
subcannot — key onsub. - Store which provider verified what, so you know whether to trust the address.
- Leave a way in if the provider is down or the user loses that account — a password or a second linked identity.
Do you need the provider's tokens afterwards?
If you only wanted identity, discard the access token after login. Keep and store a refresh token only if you will call the provider's API later — an unused stored token is pure liability.
Example
<?php
// One user, many ways to sign in.
Schema::create('identities', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('provider'); // google | github | apple | password
$table->string('provider_sub'); // stable id AT the provider
$table->string('email')->nullable(); // as seen at link time
$table->boolean('email_verified')->default(false);
$table->timestamp('last_login_at')->nullable();
$table->timestamps();
$table->unique(['provider', 'provider_sub']);
});When to use it
- A user signs up with Google, later adds a password, and can still get in when their Google account is locked.
- An app refuses to auto-link a GitHub identity to an existing account because GitHub reported the email as unverified, and asks for a password confirmation instead.
- A support case is resolved quickly because the identities table shows exactly which provider the user actually signed up with.
More examples
The linking logic, with the safe branch
Branch 4 is the one that gets skipped for convenience, and it is the whole difference between a login button and an account takeover vector.
export async function loginWithProvider(provider, claims, req) {
// 1. Known identity → straight in.
const identity = await db.identities.findOne({
provider, providerSub: claims.sub,
});
if (identity) {
await db.identities.touch(identity.id);
return { userId: identity.userId, action: 'login' };
}
// 2. No email from the provider (GitHub private, Apple relay) → new account.
if (!claims.email) {
const user = await db.users.insert({ name: claims.name ?? null });
await db.identities.insert({ provider, providerSub: claims.sub, userId: user.id });
return { userId: user.id, action: 'created' };
}
const existing = await db.users.findByEmail(claims.email.toLowerCase());
// 3. Email matches an account, and the provider VERIFIED it → safe to link.
if (existing && claims.email_verified) {
await db.identities.insert({
provider, providerSub: claims.sub, userId: existing.id,
email: claims.email, emailVerified: true,
});
return { userId: existing.id, action: 'linked' };
}
// 4. Email matches but is NOT verified → do not link. Ask for proof.
if (existing) {
return {
action: 'confirm_required',
message: 'An account with this email already exists. Sign in with your ' +
'password once, then link this provider from your settings.',
};
}
// 5. Brand new user.
const user = await db.users.insert({
email: claims.email.toLowerCase(),
name: claims.name ?? null,
emailVerified: !!claims.email_verified,
});
await db.identities.insert({
provider, providerSub: claims.sub, userId: user.id,
email: claims.email, emailVerified: !!claims.email_verified,
});
return { userId: user.id, action: 'created' };
}Rendering only the providers that are configured
The redirect URI mismatch is the number one first-time OAuth error, and it is always exact-string: http vs https, www vs bare, or a stray trailing slash.
// Feature-gate on credentials so a half-configured provider never renders a
// button that leads to a provider error page.
export function enabledProviders() {
return [
process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET && {
id: 'google', label: 'Continue with Google',
},
process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && {
id: 'github', label: 'Continue with GitHub',
},
].filter(Boolean);
}
// Login page
export default async function LoginPage() {
const providers = enabledProviders();
return (
<>
<PasswordLoginForm />
{providers.length > 0 && <Divider>or</Divider>}
{providers.map((p) => (
<a key={p.id} href={`/api/auth/${p.id}`} className="btn">{p.label}</a>
))}
</>
);
}
// The redirect URI to register with each provider:
// https://abc.com/api/auth/google/callback
// https://abc.com/api/auth/github/callback
// It must match character for character, including the scheme and any trailing slash.
Discussion