OAuth & Social Login with Passport.js in Node.js

OAuth & Social Login with Passport.js in Node.js
Social login removes the biggest barrier to user registration: the password. Users already have Google and GitHub accounts and trust them. Clicking "Login with Google" is faster and safer than creating yet another password they will forget.
This module adds Google and GitHub OAuth to the JWT API from Module 18. By the end you will have a complete social login flow, provider account linking, and a clean abstraction that makes adding more providers trivial.
This is Module 21 of the Node.js Full‑Stack Developer course.
How the OAuth Flow Works
Browser Your API Google
│ │ │
│── GET /auth/google ──────▶│ │
│ │── redirect ────────────▶│
│ │ (with state + scope) │
│◀────── redirect ──────────────────────────────────│
│ │ │
│── User approves ─────────────────────────────────▶│
│ │◀── code ───────────────│
│ │ │
│ │── POST /token ─────────▶│
│ │◀── access_token ────────│
│ │ │
│ │── GET /userinfo ────────▶│
│ │◀── profile ─────────────│
│ │ │
│ │ find or create user │
│ │ issue JWT │
│◀── redirect + JWT ────────│ │The state parameter is a random value Passport generates to prevent CSRF attacks on the callback endpoint.
Installing Dependencies
npm install passport passport-google-oauth20 passport-github2User Model — OAuth Provider Support
Extend the user model to store multiple OAuth providers:
// features/users/users.model.js
import mongoose from 'mongoose';
const providerSchema = new mongoose.Schema({
name: { type: String, required: true }, // 'google', 'github'
providerId: { type: String, required: true }, // the ID from the provider
}, { _id: false });
const userSchema = new mongoose.Schema(
{
name: { type: String, required: true, trim: true },
email: { type: String, required: true, unique: true, lowercase: true },
password: { type: String, select: false }, // optional — OAuth users may have no password
role: { type: String, enum: ['user', 'admin'], default: 'user' },
avatar: { type: String },
providers: { type: [providerSchema], default: [] },
tokenVersion: { type: Number, default: 0 },
},
{ timestamps: true }
);
export const User = mongoose.model('User', userSchema);Passport Configuration
// config/passport.js
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { Strategy as GitHubStrategy } from 'passport-github2';
import { User } from '../features/users/users.model.js';
// Shared handler — find or create a user for any OAuth provider
async function handleOAuthProfile(provider, profile, done) {
try {
const providerId = profile.id;
const email = profile.emails?.[0]?.value;
const name = profile.displayName || profile.username;
const avatar = profile.photos?.[0]?.value;
if (!email) {
return done(new Error('No email returned from provider — ensure email scope is requested'));
}
// 1. Try to find user by this provider's ID
let user = await User.findOne({
providers: { $elemMatch: { name: provider, providerId } },
});
if (user) return done(null, user);
// 2. Try to find user by email (link provider to existing account)
user = await User.findOne({ email });
if (user) {
user.providers.push({ name: provider, providerId });
if (!user.avatar) user.avatar = avatar;
await user.save();
return done(null, user);
}
// 3. Create new user
user = await User.create({
name,
email,
avatar,
providers: [{ name: provider, providerId }],
});
done(null, user);
} catch (err) {
done(err);
}
}
// ── Google Strategy ─────────────────────────────────────────────────────────
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: `${process.env.API_URL}/api/v1/auth/google/callback`,
scope: ['profile', 'email'],
},
(accessToken, refreshToken, profile, done) =>
handleOAuthProfile('google', profile, done)
)
);
// ── GitHub Strategy ──────────────────────────────────────────────────────────
passport.use(
new GitHubStrategy(
{
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: `${process.env.API_URL}/api/v1/auth/github/callback`,
scope: ['user:email'],
},
(accessToken, refreshToken, profile, done) =>
handleOAuthProfile('github', profile, done)
)
);
export default passport;Auth Router — OAuth Endpoints
// features/auth/auth.router.js
import express from 'express';
import passport from '../../config/passport.js';
import * as controller from './auth.controller.js';
import { validate } from '../../middleware/validate.js';
import { requireAuth } from '../../middleware/auth.js';
import { registerSchema, loginSchema } from './auth.schema.js';
const router = express.Router();
// ── Local auth ───────────────────────────────────────────────────────────────
router.post('/register', validate(registerSchema), controller.register);
router.post('/login', validate(loginSchema), controller.login);
router.post('/refresh', controller.refresh);
router.post('/logout', requireAuth, controller.logout);
router.get('/me', requireAuth, controller.me);
// ── Google OAuth ─────────────────────────────────────────────────────────────
// Step 1: Redirect user to Google
router.get(
'/google',
passport.authenticate('google', { session: false, scope: ['profile', 'email'] })
);
// Step 2: Handle Google callback
router.get(
'/google/callback',
passport.authenticate('google', { session: false, failureRedirect: '/login?error=google_failed' }),
controller.oauthCallback
);
// ── GitHub OAuth ──────────────────────────────────────────────────────────────
router.get(
'/github',
passport.authenticate('github', { session: false, scope: ['user:email'] })
);
router.get(
'/github/callback',
passport.authenticate('github', { session: false, failureRedirect: '/login?error=github_failed' }),
controller.oauthCallback
);
export default router;OAuth Callback Controller
// features/auth/auth.controller.js
import * as authService from './auth.service.js';
import { signAccessToken, signRefreshToken } from '../../lib/tokens.js';
const REFRESH_COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', // 'lax' (not 'strict') for OAuth callbacks — cross-site redirect
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/v1/auth/refresh',
};
// Shared handler for all OAuth providers
export function oauthCallback(req, res) {
const user = req.user; // populated by Passport after successful auth
const payload = {
sub: user._id.toString(),
role: user.role,
version: user.tokenVersion,
};
const accessToken = signAccessToken(payload);
const refreshToken = signRefreshToken(payload);
res.cookie('refreshToken', refreshToken, REFRESH_COOKIE_OPTIONS);
// Redirect to frontend with access token in URL fragment
// The frontend reads it from the URL and stores it in memory
const frontendUrl = process.env.FRONTEND_URL;
res.redirect(`${frontendUrl}/auth/callback#token=${accessToken}`);
}
// ... register, login, refresh, logout, me handlers from Module 18Wiring Passport into app.js
// app.js
import passport from './config/passport.js';
// Initialise Passport — no session middleware needed (session: false on all routes)
app.use(passport.initialize());That is all. No passport.session(), no serializeUser, no deserializeUser — because we are issuing JWTs, not session cookies.
Environment Variables
# .env
API_URL=http://localhost:3000
FRONTEND_URL=http://localhost:5173
# Google OAuth — from console.cloud.google.com
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-google-client-secret
# GitHub OAuth — from github.com/settings/developers
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secretSetting Up Google OAuth Credentials
- Go to console.cloud.google.com
- Create a project → APIs & Services → Credentials
- Create OAuth 2.0 Client ID (Web application)
- Add authorised redirect URI:
http://localhost:3000/api/v1/auth/google/callback - Copy Client ID and Client Secret to
.env
Setting Up GitHub OAuth Credentials
- Go to GitHub → Settings → Developer Settings → OAuth Apps → New OAuth App
- Set Authorization callback URL:
http://localhost:3000/api/v1/auth/github/callback - Copy Client ID and generate Client Secret
Frontend Callback Handler
The frontend receives the token from the URL fragment:
// Frontend: /auth/callback page
// URL looks like: http://localhost:5173/auth/callback#token=eyJhbGci...
const hash = window.location.hash.substring(1); // remove '#'
const params = new URLSearchParams(hash);
const accessToken = params.get('token');
if (accessToken) {
// Store in memory — NOT localStorage (XSS risk)
window.__accessToken = accessToken;
// Clear the token from the URL
window.history.replaceState({}, '', '/dashboard');
// Redirect to dashboard
window.location.href = '/dashboard';
} else {
window.location.href = '/login?error=auth_failed';
}Account Linking for Logged-In Users
Allow users who already have a local account to connect social providers:
// features/auth/auth.router.js
// Link Google to an existing account (user must be logged in)
router.get(
'/google/link',
requireAuth,
(req, res, next) => {
// Store the current user ID in the OAuth state
req.session = { linkUserId: req.user.sub };
next();
},
passport.authenticate('google', { session: false, scope: ['profile', 'email'] })
);// config/passport.js — modified handleOAuthProfile for linking
async function handleOAuthProfile(provider, profile, done, linkUserId) {
try {
// ...existing logic...
// If linkUserId is provided, add provider to that user
if (linkUserId) {
const user = await User.findById(linkUserId);
if (!user) return done(new Error('User not found'));
const alreadyLinked = user.providers.some(
p => p.name === provider && p.providerId === profile.id
);
if (!alreadyLinked) {
user.providers.push({ name: provider, providerId: profile.id });
await user.save();
}
return done(null, user);
}
// ...rest of normal flow...
} catch (err) {
done(err);
}
}Adding More Providers
The pattern is identical for every provider. Add Twitter/X:
npm install passport-twitterimport { Strategy as TwitterStrategy } from 'passport-twitter';
passport.use(
new TwitterStrategy(
{
consumerKey: process.env.TWITTER_CLIENT_ID,
consumerSecret: process.env.TWITTER_CLIENT_SECRET,
callbackURL: `${process.env.API_URL}/api/v1/auth/twitter/callback`,
includeEmail: true,
},
(token, tokenSecret, profile, done) =>
handleOAuthProfile('twitter', profile, done)
)
);// Router — two lines per provider
router.get('/twitter', passport.authenticate('twitter', { session: false }));
router.get('/twitter/callback',
passport.authenticate('twitter', { session: false, failureRedirect: '/login?error=twitter_failed' }),
controller.oauthCallback
);handleOAuthProfile is provider-agnostic — adding a new provider is two strategy lines and two route lines.
Security Notes
| Concern | Mitigation |
|---|---|
| CSRF on callback | Passport generates and validates the state parameter automatically |
| Token in URL fragment | Fragment is never sent to the server and does not appear in server logs |
| Email spoofing | Only trust the email from the provider if the provider has verified it (profile.emails[0].verified) |
| Account takeover via email | Always check verified before linking providers by email |
| Open redirect | Validate FRONTEND_URL against an allowlist — never accept a redirect URL from the request |
// Validate redirect target against allowlist
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',');
function safeRedirect(res, url) {
const target = new URL(url);
if (!ALLOWED_ORIGINS.includes(target.origin)) {
return res.status(400).json({ error: 'Invalid redirect URL' });
}
res.redirect(url);
}Node.js Full‑Stack Course — Module 21 of 32
Your API now supports social login with Google and GitHub. Continue to Module 22 to serve a React frontend directly from Express.
Summary
OAuth social login with Passport.js follows a consistent pattern for every provider:
- The OAuth flow: redirect → user approves → callback with code → exchange for token → fetch profile → find/create user → issue JWT
- Configure strategies in
config/passport.jswith a sharedhandleOAuthProfilehandler that find-or-creates users by provider ID then by email - Always pass
{ session: false }topassport.authenticate()— you are issuing JWTs, not sessions - The
oauthCallbackcontroller signs access and refresh tokens, sets the refresh cookie, and redirects the frontend to/auth/callback#token=... - Use
sameSite: 'lax'(not'strict') for the refresh cookie on OAuth callbacks — OAuth involves a cross-site redirect - Store the access token in memory on the frontend — never in localStorage
- Adding a new provider is two strategy lines + two route lines —
handleOAuthProfileis reusable
Continue to Module 22: Serving a React Frontend from Express →
