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

  1. User clicks the provider button → Authorization Code + PKCE flow.
  2. You verify the ID token (signature, iss, aud, exp, nonce).
  3. You look up (provider, sub) in an identities table.
  4. Found → log in. Not found → link or create.
  5. 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_verified is 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. sub cannot — key on sub.
  • 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

Example · php
<?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.

Example · javascript
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.

Example · javascript
// 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

  • Be the first to comment on this lesson.