Skip to content

Passport.js with Express

Passport.js is the most widely used authentication middleware for Express, not because it implements auth itself, but because it standardizes over 500 different "strategies" behind one consistent API. This lesson covers its core concepts using the Local strategy.

How Passport.js Is Structured

Passport itself does very little: it defines a common interface, Strategy, authenticate(), serializeUser/deserializeUser, and delegates the actual verification logic to whichever strategy package you install (passport-local, passport-google-oauth20, passport-github2, and hundreds more).

Because every strategy implements the same interface, switching from username/password login to adding "Sign in with Google" later means installing a new strategy package, not rewriting your authentication flow.

const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;

passport.use(new LocalStrategy(
  { usernameField: 'email' },
  async (email, password, done) => {
    const user = await User.findOne({ email });
    if (!user || !(await user.comparePassword(password))) {
      return done(null, false, { message: 'Invalid credentials' });
    }
    done(null, user);
  }
));

The strategy's callback receives credentials and calls done(err, user, info), done(null, false, info) signals a failed login without an error.

Wiring Passport Into Express

app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));
app.use(passport.initialize());
app.use(passport.session());

app.post('/login', passport.authenticate('local'), (req, res) => {
  res.json({ user: req.user });
});
  • passport.initialize() and passport.session() must be registered after express-session.
  • passport.authenticate(strategyName) runs the named strategy as route middleware.
  • serializeUser/deserializeUser control what identifies a user in the session (usually just their ID).
  • On success, req.user is automatically populated for the rest of that request, and future requests via the session.

Passport.js Cheatsheet

The setup steps every Passport-based login flow needs.

Step Code
Install core + strategy npm install passport passport-local
Define a strategy passport.use(new LocalStrategy(...))
Serialize user passport.serializeUser((user, done) => done(null, user.id))
Deserialize user passport.deserializeUser(async (id, done) => {...})
Initialize app.use(passport.initialize())
Protect a route passport.authenticate('local')

serializeUser and deserializeUser

These two functions control what actually gets stored in the session versus what gets attached to req.user on later requests. Typically, only the user's ID is stored in the session, and the full user record is re-fetched from the database on each request.

passport.serializeUser((user, done) => {
  done(null, user.id);
});

passport.deserializeUser(async (id, done) => {
  const user = await User.findById(id);
  done(null, user);
});

Custom Callback Handling

Instead of letting passport.authenticate() handle the response automatically, you can pass a callback for full control over the success/failure response, useful for API-style responses instead of redirects.

app.post('/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({ user });
    });
  })(req, res, next);
});

Common Mistakes

  • Forgetting to register express-session before passport.session(), breaking persistent login.
  • Storing the entire user object in the session instead of just the ID via serializeUser.
  • Mixing up passport.authenticate()'s default redirect-based flow with an API that expects JSON responses.
  • Not handling the info object from a failed strategy callback, losing useful failure context.

Key Takeaways

  • Passport.js standardizes many authentication methods behind one consistent strategy interface.
  • passport-local handles traditional username/password login; other strategies handle third-party providers.
  • serializeUser/deserializeUser control what's stored in the session versus attached to req.user.
  • A custom callback gives full control over API-style JSON responses instead of Passport's default redirects.

Pro Tip

For pure JSON APIs, always use the custom callback form of passport.authenticate() rather than the default middleware form, the default behavior is built around redirect-based, server-rendered flows.