Hand-written login code works for one method. Once a product needs email and password and "Sign in with Google", the logic for verifying credentials and loading the current user starts to repeat. Passport standardises this: each login method is a strategy with the same shape, and Passport handles the session plumbing. After this lesson you will be able to add local and Google OAuth login to an Express app, protect routes, and log users out safely.
Passport does not store users or manage sessions itself; it sits on top of express-session.
npm install passport passport-local passport-google-oauth20// src/app.js, after app.use(session(...))
import passport from "passport";
import "./config/passport.js"; // registers strategies
app.use(passport.initialize());
app.use(passport.session()); // reads the session, populates req.userTwo callbacks decide what goes into the session and how the user is restored later. Store only the ID:
// src/config/passport.js
import passport from "passport";
import { User } from "../models/user.model.js";
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser(async (id, done) => {
try {
done(null, await User.findById(id));
} catch (err) {
done(err);
}
});A strategy's verify function receives the credentials and calls done(error, user, info); false as the user signals a failed login.
import { Strategy as LocalStrategy } from "passport-local";
passport.use(new LocalStrategy({ usernameField: "email" }, async (email, password, done) => {
try {
const user = await User.findOne({ email: email.toLowerCase() }).select("+password");
if (!user || !(await user.checkPassword(password))) {
return done(null, false, { message: "Invalid email or password" });
}
done(null, user);
} catch (err) {
done(err);
}
}));checkPassword is the bcrypt method from the Mongoose lesson; one generic failure message for both unknown email and wrong password prevents account enumeration.
For server-rendered apps, passport.authenticate("local", { failureRedirect: "/login" }) as route middleware is enough. JSON clients need the custom-callback form so you control the response:
router.post("/api/login", (req, res, next) => {
passport.authenticate("local", (err, user, info) => {
if (err) return next(err);
if (!user) return res.status(401).json({ error: info.message });
req.logIn(user, (loginErr) => {
if (loginErr) return next(loginErr);
res.json({ id: user.id, email: user.email });
});
})(req, res, next);
});
router.post("/logout", (req, res, next) => {
req.logout((err) => (err ? next(err) : res.sendStatus(204)));
});Since Passport 0.6, req.logIn() regenerates the session ID to block fixation, and req.logout() requires a callback because it clears and saves the session asynchronously.
OAuth delegates the password to Google: the user is redirected there, approves, and returns with a code that Passport exchanges for a profile. Register an OAuth client in Google Cloud Console with your callback URL as the authorised redirect URI, and keep the secret in .env.
import { Strategy as GoogleStrategy } from "passport-google-oauth20";
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: "/auth/google/callback",
}, async (accessToken, refreshToken, profile, done) => {
try {
const email = profile.emails?.[0]?.value;
const user =
(await User.findOne({ $or: [{ googleId: profile.id }, { email }] })) ??
(await User.create({ googleId: profile.id, email, name: profile.displayName }));
done(null, user);
} catch (err) {
done(err);
}
}));
router.get("/auth/google", passport.authenticate("google", { scope: ["profile", "email"] }));
router.get("/auth/google/callback",
passport.authenticate("google", { failureRedirect: "/login" }),
(req, res) => res.redirect("/dashboard"));The verify function's job is find or create: match an existing account by provider ID or email, otherwise create one.
export const ensureAuth = (req, res, next) =>
req.isAuthenticated() ? next() : res.status(401).json({ error: "Login required" });
router.get("/dashboard", ensureAuth, (req, res) => res.render("dashboard"));Keep deserializeUser cheap: it runs on every authenticated request. For token-only APIs, the passport-jwt strategy reads a bearer token instead of a session.
What should `serializeUser` store in the session?
initialize() and session() connect it to express-session.serializeUser stores the ID, deserializeUser loads the user, and req.user becomes available everywhere.done(err, user, info); use a generic failure message.req.isAuthenticated() and log out with the callback form of req.logout().Next lesson: Authorization: Roles and Permission Middleware — decide what an authenticated user is allowed to do with role checks and ownership rules.