Skip to main content

🛂 Passport.js Strategies

You just learned the OAuth dance. Now meet the partner that makes Node.js dance it flawlessly. Passport.js is authentication middleware that hides the ceremony of dozens of login methods behind one clean, consistent pattern — so your app code stays focused on your app.

Week 9 · Thursday: OAuth and Social Login · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what a Passport strategy is and how the done callback signals success, failure, or error
  • Wire up Passport in an Express app with initialize() and session()
  • Describe the serializeUser / deserializeUser session cycle and why only the user ID goes in the session
  • Implement the Local strategy with hashed passwords
  • Configure the passport-google-oauth20 strategy for social login
  • Combine multiple named strategies and protect routes with authentication guards

Estimated Time: 70 minutes

Practice: Build a Local-strategy verify function and a route guard, then add a Google strategy.

In This Lesson

What Is Passport.js?

Passport is authentication middleware for Node.js. Its genius is a single, uniform interface across every authentication method — username/password, Google, GitHub, SAML, JWT, magic links — each implemented as a pluggable strategy. Your route code always looks the same; only the strategy underneath changes.

🔌 Analogy: the universal power adapter

Think of Passport as the travel adapter in your bag. The adapter's job never changes — plug your device into one side, the wall socket into the other. What changes per country is the little plug shape you clip on. A Passport strategy is that clip: passport-local for passwords, passport-google-oauth20 for Google, passport-jwt for tokens. Swap the clip, keep the adapter.

There are three core ideas you'll use in every Passport app:

  • Strategy — a plugin that knows how to verify one kind of credential.
  • Verify callbackyour function that a strategy calls to look up or create the user, then reports back via done().
  • Serialize / deserialize — how Passport stores a logged-in user in the session and restores them on later requests.
graph TD A["Incoming request"] --> B{"passport.authenticate"} B -->|runs| C["Strategy verify callback"] C -->|"done(null, user)"| D["serializeUser stores user id"] C -->|"done(null, false)"| E["401 / redirect to login"] C -->|"done(err)"| F["Error handler"] D --> G["Session cookie set"] H["Later request"] --> I["deserializeUser loads user"] I --> J["req.user is available"]

Setting Up Passport

Passport rides on top of Express sessions. Install the pieces, configure a session store, then initialize Passport.

// npm install express express-session passport
const express = require('express');
const session = require('express-session');
const passport = require('passport');

const app = express();
app.use(express.urlencoded({ extended: false }));

// 1. Sessions must come BEFORE passport.session()
app.use(session({
  secret: process.env.SESSION_SECRET,   // strong, random, from the environment
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,                      // JS can't read the cookie
    secure: process.env.NODE_ENV === 'production', // HTTPS-only in prod
    sameSite: 'lax',                     // CSRF hardening
    maxAge: 24 * 60 * 60 * 1000          // 24 hours
  }
}));

// 2. Initialize Passport and connect it to the session
app.use(passport.initialize());
app.use(passport.session());

💡 Order matters

passport.session() is itself middleware that reads the session to restore the user. It must be registered after express-session. Get the order wrong and req.user will silently be undefined on every request.

serializeUser & deserializeUser

When a user logs in, Passport needs to remember them across requests without stuffing the whole user object into the cookie. It solves this with two functions you provide.

  • serializeUser runs once at login. It decides the minimal token to store in the session — almost always just the user's database ID.
  • deserializeUser runs on every subsequent request. It takes that ID back out of the session and loads the full user, attaching it to req.user.
// Store ONLY the id in the session — keep the cookie small and safe
passport.serializeUser((user, done) => {
  done(null, user.id);
});

// On each request, turn the stored id back into a full user object
passport.deserializeUser(async (id, done) => {
  try {
    const user = await User.findById(id);
    done(null, user);          // becomes req.user
  } catch (err) {
    done(err);
  }
});
serializeUser writes the user id into the session; deserializeUser reads it back to load req.user Login (once) serializeUser user → id Session cookie { id: "abc123" } Each request deserializeUser id → req.user
Only the ID lives in the session. The full user is re-fetched fresh on every request, so a role change or ban takes effect immediately.

The Local Strategy

The Local strategy (passport-local) is classic username-and-password auth against your own database. Your verify callback finds the user, compares the password against a stored hash, and calls done().

// npm install passport-local bcrypt
const LocalStrategy = require('passport-local').Strategy;
const bcrypt = require('bcrypt');
const User = require('./models/User');

passport.use(new LocalStrategy(
  { usernameField: 'email' },              // use "email" instead of "username"
  async (email, password, done) => {
    try {
      const user = await User.findOne({ email });
      if (!user) {
        // Generic message — don't reveal WHICH part was wrong
        return done(null, false, { message: 'Invalid email or password' });
      }

      const isMatch = await bcrypt.compare(password, user.password);
      if (!isMatch) {
        return done(null, false, { message: 'Invalid email or password' });
      }

      return done(null, user);             // success!
    } catch (err) {
      return done(err);                    // unexpected error
    }
  }
));

// The login route just names the strategy
app.post('/login', passport.authenticate('local', {
  successRedirect: '/profile',
  failureRedirect: '/login'
}));

✅ Reading the done callback

The three signatures are the whole contract:

  • done(null, user) → authentication succeeded.
  • done(null, false, info) → credentials were bad (a normal failure, not a crash).
  • done(err) → something broke (DB down, etc.) — routed to your error handler.

⚠️ Never store raw passwords

Always hash with a slow, salted algorithm — bcrypt or argon2 — and compare with the library's timing-safe compare. Storing plaintext (or a fast hash like MD5/SHA-1) means one database leak exposes every user's password.

The Google OAuth Strategy

Here's where last lesson's OAuth theory becomes one tidy config object. The passport-google-oauth20 strategy runs the entire Authorization Code flow for you — the redirect, the consent screen, the token exchange — and hands your verify callback a ready-made profile.

// npm install passport-google-oauth20
const GoogleStrategy = require('passport-google-oauth20').Strategy;

passport.use(new GoogleStrategy(
  {
    clientID: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET, // server-side secret — safe here
    callbackURL: '/auth/google/callback',           // must match the registered URI
    scope: ['profile', 'email']
  },
  // accessToken/refreshToken are the provider's tokens; profile is normalized user data
  async (accessToken, refreshToken, profile, done) => {
    try {
      // Look the user up by their Google id, or create them on first login
      let user = await User.findOne({ googleId: profile.id });
      if (!user) {
        user = await User.create({
          googleId: profile.id,
          name: profile.displayName,
          email: profile.emails?.[0]?.value,
          avatar: profile.photos?.[0]?.value
          // no password — this is a social-only account
        });
      }
      return done(null, user);
    } catch (err) {
      return done(err);
    }
  }
));

// Route 1: kick off the flow — Passport redirects to Google
app.get('/auth/google',
  passport.authenticate('google', { scope: ['profile', 'email'] })
);

// Route 2: Google redirects back here; Passport finishes the token exchange
app.get('/auth/google/callback',
  passport.authenticate('google', {
    failureRedirect: '/login',
    successRedirect: '/profile'
  })
);

💡 Where did all the OAuth ceremony go?

Every step from the previous lesson still happens — building the /authorize URL, carrying state, POSTing to the token endpoint with the client_secret. The strategy just does it internally. Your clientSecret stays server-side because this whole strategy runs on your server, never in the browser.

Multiple & Named Strategies

Real apps offer several ways in. Register as many strategies as you like — each gets a name (defaulting to the package's name), and routes pick which to run. You can even hand authenticate an array of names to try in order.

// Give strategies explicit names when you need more than one of a kind
passport.use('local-login', new LocalStrategy({ usernameField: 'email' }, verifyLogin));
passport.use('local-admin', new LocalStrategy({ usernameField: 'email' }, verifyAdmin));
passport.use('google', new GoogleStrategy(googleOptions, verifyGoogle));

// Each route names the strategy it wants
app.post('/login', passport.authenticate('local-login', {
  successRedirect: '/profile', failureRedirect: '/login'
}));

app.post('/admin/login', passport.authenticate('local-admin', {
  successRedirect: '/admin', failureRedirect: '/admin/login'
}));

// Accept EITHER a JWT or an API key on an API route (first to succeed wins)
app.get('/api/data',
  passport.authenticate(['jwt', 'apikey'], { session: false }),
  (req, res) => res.json({ data: 'protected' })
);

📖 { session: false } for APIs

Token-based API auth is stateless — every request carries its own proof, so there's no session to create. Passing { session: false } tells Passport to skip serializeUser entirely. Use it for JWT and API-key strategies; leave it off for browser session logins.

Protecting Routes

Once a user is logged in, Passport exposes req.isAuthenticated() and req.user. Wrap protected routes in a tiny guard middleware.

// A reusable guard
function ensureAuthenticated(req, res, next) {
  if (req.isAuthenticated()) return next();
  res.redirect('/login');
}

// Apply it to any route that needs a logged-in user
app.get('/profile', ensureAuthenticated, (req, res) => {
  res.send(`Welcome, ${req.user.name}!`);
});

// Logout is async in modern Passport (v0.6+): pass a callback
app.post('/logout', (req, res, next) => {
  req.logout(err => {
    if (err) return next(err);
    res.redirect('/');
  });
});

⚠️ req.logout changed

In Passport 0.6+, req.logout() is asynchronous and requires a callback. Old tutorials call req.logout() with no arguments — that throws now. Always pass the callback shown above.

Practice & Quiz

🏋️ Exercise 1: Write a Local verify callback

Goal: Complete verify(email, password, done) for a Local strategy. Look the user up by email, compare a bcrypt-hashed password, and call done correctly for the three outcomes.

const bcrypt = require('bcrypt');
async function verify(email, password, done) {
    // TODO: find user, compare password, call done() appropriately
}
💡 Hint

Wrap the DB work in try/catch. A missing user or a bad password are both done(null, false, { message }). Only a thrown error is done(err). Use the same generic message for both failure cases.

✅ Solution
async function verify(email, password, done) {
    try {
        const user = await User.findOne({ email });
        if (!user) {
            return done(null, false, { message: 'Invalid email or password' });
        }
        const ok = await bcrypt.compare(password, user.password);
        if (!ok) {
            return done(null, false, { message: 'Invalid email or password' });
        }
        return done(null, user);
    } catch (err) {
        return done(err);
    }
}

🏋️ Exercise 2: A role-checking guard

Goal: Write requireRole(role) — a middleware factory that returns a guard allowing only authenticated users whose req.user.role matches.

✅ Solution
function requireRole(role) {
    return (req, res, next) => {
        if (!req.isAuthenticated()) return res.redirect('/login');
        if (req.user.role !== role) return res.status(403).send('Forbidden');
        next();
    };
}

// Usage:
app.get('/admin', requireRole('admin'), (req, res) => res.send('Admin area'));

Returning a middleware from a function lets you reuse the same logic with different roles — a common Express pattern.

🎯 Quick Quiz

Question 1: What does serializeUser typically store in the session?

Question 2: A user submits a wrong password. How should the verify callback respond?

Question 3: Why pass { session: false } when authenticating an API request with JWT?

Best Practices & Pitfalls

✅ Do

  • Register express-session before passport.session()
  • Store only the user ID in the session; re-fetch the user in deserializeUser
  • Hash passwords with bcrypt or argon2 and compare with the library's function
  • Return generic error messages so you don't leak whether an account exists
  • Keep every clientSecret and SESSION_SECRET in environment variables
  • Use a persistent session store (Redis, Mongo) in production — not the default MemoryStore

❌ Don't

  • Don't put the whole user object (or any secret) into the session cookie
  • Don't forget the callback in req.logout(cb) on Passport 0.6+
  • Don't skip { session: false } on stateless API strategies
  • Don't reveal "user not found" vs "wrong password" — attackers use that to enumerate accounts
  • Don't rely on the in-memory MemoryStore in production — it leaks memory and drops sessions on restart

Summary

🎉 Key Takeaways

  • Passport wraps every login method in a uniform strategy interface
  • Your verify callback signals results via done(null, user), done(null, false, info), or done(err)
  • serializeUser stores just the ID; deserializeUser reloads the full user each request
  • The Local strategy checks a bcrypt hash; passport-google-oauth20 runs the whole OAuth flow for you
  • Register multiple named strategies and guard routes with req.isAuthenticated()

📚 Additional Resources

🚀 What's Next?

You have the protocol and the library. In the final lesson of the day you'll put them together into a complete, production-shaped app: Implementing Social Login — user model, multi-provider config, account linking, and the routes that tie it all together.

🎉 Nicely done!

One adapter, many plugs. You can now add any login method without rewriting your app.