Skip to content
Express.js ex libraries 5 min read

passport Authentication

Authentication is a problem every app solves the same handful of ways — a local username and password, an OAuth handshake with Google or GitHub, a bearer token, an API key. Passport is the Express ecosystem’s answer to all of them: a tiny, unopinionated middleware layer that delegates the actual “is this request who it claims to be?” decision to interchangeable plugins called strategies. You pick the strategies you need, tell Passport how to turn a verified user into a session, and it exposes req.user to the rest of your app. With over 500 strategies on offer, the mechanism stays identical whether you authenticate against a database, an SSO provider, or a JWT.

Installing and initializing

Install Passport plus at least one strategy. The local strategy authenticates against credentials you store yourself, so it is the natural starting point. Passport itself is just middleware — mount passport.initialize() on every request, and passport.session() only if you want it to restore a logged-in user from an express-session cookie.

npm install passport passport-local express-session
const express = require("express");
const session = require("express-session");
const passport = require("passport");

const app = express();
app.use(express.json());

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

app.use(passport.initialize()); // attach Passport to req
app.use(passport.session()); // restore req.user from the session

app.listen(3000, () => console.log("Listening on http://localhost:3000"));

Order matters. passport.session() reads the session, so it must come after the express-session middleware and after passport.initialize(). If you only use stateless strategies like JWT, you can drop passport.session() and the session middleware entirely.

Defining a strategy

A strategy is configured once at startup with a verify callback — the function that takes the raw credentials, looks up the user, and calls done(err, user) to report the result. Return done(null, false) for a failed login (no error, just no user) and done(err) for an unexpected failure. Here the LocalStrategy is told to use the email field instead of the default username.

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

passport.use(
  new LocalStrategy({ usernameField: "email" }, async (email, password, done) => {
    try {
      const user = await db.users.findByEmail(email);
      if (!user) return done(null, false, { message: "Unknown user" });

      const ok = await bcrypt.compare(password, user.passwordHash);
      if (!ok) return done(null, false, { message: "Wrong password" });

      return done(null, user); // success — user becomes req.user
    } catch (err) {
      return done(err); // unexpected error
    }
  })
);

Serialize and deserialize

When sessions are in play, Passport does not store the whole user object in the session — that would be wasteful and quickly stale. Instead serializeUser decides what minimal key to persist (almost always the user id), and deserializeUser runs on every subsequent request to turn that key back into a full req.user.

passport.serializeUser((user, done) => {
  done(null, user.id); // store just the id in the session
});

passport.deserializeUser(async (id, done) => {
  try {
    const user = await db.users.findById(id);
    done(null, user); // becomes req.user on this request
  } catch (err) {
    done(err);
  }
});

These two hooks are only needed for session-based auth. Stateless strategies skip them because there is nothing to persist between requests.

Authenticating a route

passport.authenticate(strategyName, options) returns ordinary middleware you drop in front of a route. On success it sets req.user and (for session strategies) writes the session; on failure it short-circuits with a 401 or redirects, depending on the options.

// Form-style login that redirects
app.post(
  "/login",
  passport.authenticate("local", {
    successRedirect: "/dashboard",
    failureRedirect: "/login",
  })
);

// API-style login with a custom callback for full control
app.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);
      return res.json({ id: user.id, email: user.email });
    });
  })(req, res, next);
});

A guard middleware then protects everything else, and req.logout ends the session:

function requireAuth(req, res, next) {
  if (req.isAuthenticated()) return next();
  return res.status(401).json({ error: "Not authenticated" });
}

app.get("/profile", requireAuth, (req, res) => {
  res.json({ id: req.user.id, email: req.user.email });
});

app.post("/logout", (req, res, next) => {
  req.logout((err) => {
    if (err) return next(err);
    res.json({ ok: true });
  });
});

A successful API login responds with the user and a session cookie:

Output:

< HTTP/1.1 200 OK
< Set-Cookie: connect.sid=s%3Af3Kd...; Path=/; HttpOnly; SameSite=Lax
< Content-Type: application/json
{"id":42,"email":"[email protected]"}

The strategy ecosystem

The same passport.use / passport.authenticate flow powers every provider — you only swap the strategy package and its verify callback. A few of the most common:

Strategy packageUse caseSession?
passport-localUsername / password against your own storeYes
passport-google-oauth20”Sign in with Google” OAuth 2.0Yes
passport-github2”Sign in with GitHub” OAuth 2.0Yes
passport-jwtStateless APIs using a bearer JWTNo
passport-http-bearerGeneric bearer token / API keysNo

For a stateless API you reach for jsonwebtoken to mint tokens and passport-jwt to verify them — no session, no serialize hooks, just a verify callback that trusts the decoded payload:

const { Strategy: JwtStrategy, ExtractJwt } = require("passport-jwt");

passport.use(
  new JwtStrategy(
    {
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.JWT_SECRET,
    },
    async (payload, done) => {
      const user = await db.users.findById(payload.sub);
      return user ? done(null, user) : done(null, false);
    }
  )
);

// Protect a route with no session at all
app.get(
  "/api/me",
  passport.authenticate("jwt", { session: false }),
  (req, res) => res.json({ id: req.user.id })
);

Best Practices

  • Keep verify callbacks thin and async — do the database lookup there and return early with done(null, false) on any mismatch.
  • Never store plaintext passwords; hash with bcrypt and compare in the local strategy’s verify callback.
  • Serialize only the user id, and let deserializeUser re-fetch the current record so role and status changes take effect immediately.
  • Mount passport.session() and passport.initialize() after the session middleware, and set { session: false } for stateless JWT routes.
  • Use OAuth strategies (passport-google-oauth20, passport-github2) instead of asking users for passwords when an SSO provider is available.
  • Call req.logout and destroy the session on sign-out, and protect routes with an isAuthenticated-based guard rather than ad-hoc checks.
  • Read all client secrets and JWT keys from environment variables loaded with dotenv, never from source.
Last updated June 14, 2026
Was this helpful?