π Implementing Social Login
Theory meets keyboard. In this capstone of the OAuth day you'll assemble a real social-login system: a user model that holds several providers at once, one shared callback that normalizes every provider, account linking, and the security guardrails that make it production-ready.
Week 9 · Thursday: OAuth and Social Login · Lecture 3
π― Learning Objectives
By the end of this lesson, you will be able to:
- Design a User model that stores multiple provider IDs and an optional password
- Write a shared OAuth callback that normalizes profiles from any provider
- Implement account linking so one person with many logins is one user
- Register Google and GitHub strategies and their authentication routes
- Configure secure sessions with a persistent store and hardened cookies
- Explain the trade-offs of social login and why to keep an email/password fallback
Estimated Time: 75 minutes
Project: Wire a multi-provider login end to end β model, strategy callback, routes, and a linking flow.
In This Lesson
Why Social Login?
Social login β "Sign in with Google / GitHub / Apple" β lets users authenticate with an account they already have. It's everywhere for good reasons, but it's a trade, not a free win.
| Benefit | Cost to weigh |
|---|---|
| No new password to invent or remember | You depend on a third party's uptime and policies |
| Higher sign-up conversion β less friction | Different providers return different profile fields |
| Auth security (2FA, breach detection) delegated to Google/GitHub | Losing the social account can lock users out |
| No password-reset or email-verification flows to build | Account-linking logic adds real complexity |
π‘ Always offer a fallback
Keep an email/password option (or a magic link) alongside social buttons. It rescues users who deleted their Google account, and it keeps you from being 100% hostage to a single provider's decisions.
The Big Picture
Every social login boils down to the same shape: send the user to the provider, get a profile back, find-or-create a local user, start a session. Here's the whole journey.
The clever part is the "find or create" box. With several providers, you want one function that handles them all consistently β that's the heart of this lesson.
The User Model
A social-ready user needs a slot for each provider's ID, an optional password (social-only users have none), and helper methods to report which providers are connected. This example uses Mongoose, but the shape applies to any database.
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
password: { type: String }, // optional β social-only users have none
avatar: { type: String },
// One field per provider β a single user can connect several
googleId: String,
githubId: String,
emailVerified: { type: Boolean, default: false }
}, { timestamps: true }); // adds createdAt / updatedAt automatically
// Hash the password before saving, but ONLY if it changed and exists
userSchema.pre('save', async function (next) {
if (!this.isModified('password') || !this.password) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
// Compare a candidate password against the stored hash
userSchema.methods.comparePassword = function (candidate) {
if (!this.password) return Promise.resolve(false);
return bcrypt.compare(candidate, this.password);
};
// List every way this user can log in
userSchema.methods.getProviders = function () {
const providers = [];
if (this.googleId) providers.push('google');
if (this.githubId) providers.push('github');
if (this.password) providers.push('local');
return providers;
};
module.exports = mongoose.model('User', userSchema);
β Why an optional password?
Someone who only ever "Signs in with Google" never sets a password β forcing one would defeat the purpose. Making the field optional lets social-only and hybrid accounts coexist in one collection, and getProviders() tells your UI exactly which login buttons to show as "connected."
One Callback for Every Provider
Rather than copy-pasting near-identical logic into each strategy, write a single handleOAuthLogin that every provider funnels through. It does three things in priority order: match by provider ID, else link by verified email, else create a new user.
// config/oauth.js
const User = require('../models/User');
// Normalize each provider's differently-shaped profile into a common object
function normalizeProfile(profile, provider) {
return {
name: profile.displayName || profile.username,
email: profile.emails?.[0]?.value,
avatar: profile.photos?.[0]?.value,
// Providers signal verification differently; treat Google/GitHub emails as verified
emailVerified: provider === 'google' || Boolean(profile.emails?.[0]?.verified)
};
}
// Shared find-or-create used by ALL OAuth strategies
async function handleOAuthLogin(provider, profile, done) {
try {
const idField = `${provider}Id`; // 'googleId', 'githubId', ...
const data = normalizeProfile(profile, provider);
// 1. Already linked? Log them straight in.
let user = await User.findOne({ [idField]: profile.id });
if (user) return done(null, user);
// 2. Same verified email on another provider? Link this provider to it.
if (data.email) {
user = await User.findOne({ email: data.email });
if (user) {
user[idField] = profile.id;
if (!user.avatar) user.avatar = data.avatar;
if (data.emailVerified) user.emailVerified = true;
await user.save();
return done(null, user);
}
}
// 3. Brand-new user.
user = await User.create({
name: data.name,
email: data.email || `${profile.id}@${provider}.local`, // fallback if no email
avatar: data.avatar,
emailVerified: data.emailVerified,
[idField]: profile.id
});
return done(null, user);
} catch (err) {
return done(err);
}
}
module.exports = { handleOAuthLogin };
β οΈ Only auto-link on a verified email
Automatically merging accounts by email is convenient but dangerous if the email isn't verified: an attacker could register a provider account with your victim's email and get merged into their account. Link automatically only when the provider guarantees the email is verified; otherwise require the already-logged-in user to confirm the link explicitly.
Registering the Strategies
With the shared callback in place, each strategy is a thin config wrapper. Note how little is left per provider.
// config/passport.js
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const GitHubStrategy = require('passport-github2').Strategy;
const User = require('../models/User');
const { handleOAuthLogin } = require('./oauth');
// Session plumbing (from the previous lesson)
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser(async (id, done) => {
try { done(null, await User.findById(id)); }
catch (err) { done(err); }
});
passport.use(new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET, // server-side only
callbackURL: '/auth/google/callback',
scope: ['profile', 'email']
},
(accessToken, refreshToken, profile, done) =>
handleOAuthLogin('google', profile, done)
));
passport.use(new GitHubStrategy(
{
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET, // server-side only
callbackURL: '/auth/github/callback',
scope: ['user:email']
},
(accessToken, refreshToken, profile, done) =>
handleOAuthLogin('github', profile, done)
));
module.exports = passport;
π‘ Adding a third provider is now trivial
Want Microsoft or Discord next? Add the ID field to the model, install the strategy package, and register one more passport.use(...) that calls the same handleOAuthLogin. The find-or-create logic never changes. That's the payoff of the shared callback.
Authentication Routes
Each provider needs two routes: one to start the flow, one to receive the callback. Logout and a link/unlink pair round it out.
// routes/auth.js
const express = require('express');
const passport = require('passport');
const router = express.Router();
// --- Google ---
router.get('/google',
passport.authenticate('google', { scope: ['profile', 'email'] }));
router.get('/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => res.redirect('/profile')); // success handler
// --- GitHub ---
router.get('/github',
passport.authenticate('github', { scope: ['user:email'] }));
router.get('/github/callback',
passport.authenticate('github', { failureRedirect: '/login' }),
(req, res) => res.redirect('/profile'));
// --- Logout (async callback required on Passport 0.6+) ---
router.post('/logout', (req, res, next) => {
req.logout(err => {
if (err) return next(err);
res.redirect('/');
});
});
module.exports = router;
Unlinking a provider safely
Users should be able to disconnect a provider β but never the last one, or they'd lock themselves out.
// Guard: must be logged in
function ensureAuth(req, res, next) {
if (req.isAuthenticated()) return next();
res.redirect('/login');
}
router.post('/unlink/:provider', ensureAuth, async (req, res, next) => {
try {
const { provider } = req.params;
const user = await User.findById(req.user.id);
// Refuse to remove the only remaining login method
if (user.getProviders().length <= 1) {
return res.status(400).send('Cannot unlink your only login method');
}
if (provider === 'local') user.password = undefined;
else user[`${provider}Id`] = undefined;
await user.save();
res.redirect('/profile');
} catch (err) {
next(err);
}
});
Session & App Security
The last mile is wiring the app together with production-grade session and header settings. The default in-memory session store is fine for a demo but leaks memory and forgets everyone on restart β use a real store.
// app.js (key security pieces)
const session = require('express-session');
const MongoStore = require('connect-mongo');
const helmet = require('helmet');
app.use(helmet()); // sensible secure HTTP headers
app.use(session({
secret: process.env.SESSION_SECRET, // long, random, secret
resave: false,
saveUninitialized: false,
store: MongoStore.create({ mongoUrl: process.env.MONGODB_URI }), // persistent
cookie: {
httpOnly: true, // JS can't read the cookie
secure: process.env.NODE_ENV === 'production', // HTTPS-only in prod
sameSite: 'lax', // CSRF hardening; 'lax' allows the OAuth redirect
maxAge: 24 * 60 * 60 * 1000
}
}));
app.use(passport.initialize());
app.use(passport.session());
β οΈ SameSite and the OAuth redirect
Use sameSite: 'lax', not 'strict', for the session cookie in an OAuth app. With strict, the cookie isn't sent when the browser lands back on your callback from the provider's domain, so your session (and the stored state) appears empty and login mysteriously fails. lax allows top-level navigations like the OAuth return while still blocking cross-site POST forgery.
Security checklist
- All traffic over HTTPS β OAuth tokens must never cross plain HTTP
- Client secrets in environment variables, never in the repo
- Register exact callback URLs with each provider; validate redirect URIs
- HttpOnly + Secure + SameSite cookies, backed by a persistent session store
- Request minimum scopes; store any provider tokens encrypted
Practice & Quiz
ποΈ Exercise 1: Normalize a GitHub profile
Goal: Write normalizeProfile(profile, provider) returning { name, email, avatar }. GitHub may lack a displayName, so fall back to username, and both emails and photos may be missing.
function normalizeProfile(profile, provider) {
// TODO: return { name, email, avatar } handling missing fields safely
}
π‘ Hint
Use optional chaining (profile.emails?.[0]?.value) so a missing array doesn't throw, and the logical-OR fallback profile.displayName || profile.username for the name.
β Solution
function normalizeProfile(profile, provider) {
return {
name: profile.displayName || profile.username,
email: profile.emails?.[0]?.value,
avatar: profile.photos?.[0]?.value
};
}
Optional chaining is the key: providers are inconsistent about which fields they return, so defensive access keeps one missing field from crashing the whole login.
ποΈ Exercise 2: Guard the last login method
Goal: Write canUnlink(user, provider) returning true only if removing provider would leave the user with at least one other way to log in.
β Solution
function canUnlink(user, provider) {
const providers = user.getProviders();
// Must currently have it, and have more than one total
return providers.includes(provider) && providers.length > 1;
}
This prevents the classic self-lockout: unlink your only provider and you can never sign in again.
π― Quick Quiz
Question 1: Why is the password field optional on the User model?
Question 2: Auto-linking two accounts by email is only safe whenβ¦
Question 3: Which cookie sameSite value keeps sessions working through an OAuth redirect?
Best Practices & Pitfalls
β Do
- Funnel every provider through one shared find-or-create callback
- Store one provider-ID field per provider plus an optional password
- Auto-link accounts only on a verified email
- Prevent users from unlinking their only login method
- Use a persistent session store and
sameSite: 'lax'cookies - Keep an email/password fallback so you're not hostage to one provider
β Don't
- Don't duplicate login logic across strategies β share it
- Don't merge accounts on an unverified email β that's an account-takeover vector
- Don't ship the default MemoryStore to production
- Don't use
sameSite: 'strict'for the OAuth session cookie - Don't expose client secrets in front-end code or version control
Summary
π Key Takeaways
- A social-ready User model holds one ID per provider and an optional password
- One shared callback normalizes profiles and does find-by-ID β link-by-email β create
- Account linking is safe only on verified emails; guard against unlinking the last method
- Each provider is a thin strategy wrapper plus a start route and a callback route
- Production needs a persistent session store and
lax,HttpOnly,Securecookies
π Additional Resources
- passportjs.org β Google OAuth guide
- oauth.net β OAuth 2.0 reference
- Google β OAuth 2.0 for developers
- OWASP β Session Management Cheat Sheet
π What's Next?
You can now let users in securely through many doors. But every door is also a potential attack surface. Next you'll learn how those attacks work and how to shut them down: Common Security Vulnerabilities β XSS, CSRF, injection, and the defenses every full-stack developer must know.
π Capstone complete!
You've built a real, multi-provider login system end to end. That's a feature shipping teams charge real money for.