Passport Local Strategy
Passport is the de-facto authentication middleware for Express, and passport-local is the strategy that handles the most common case: logging a user in with a username (or email) and a password. Rather than scattering credential checks across your routes, Passport gives you a single place to verify users and a uniform req.user everywhere downstream. This page wires up passport-local, writes the verify callback, configures serializeUser/deserializeUser for session persistence, and protects routes with passport.authenticate.
How Passport fits together
Passport sits between your session middleware and your route handlers. The strategy (passport-local) knows how to extract credentials from the request; the verify callback you supply knows how to validate them against your database. Once a user is verified, Passport stores a minimal reference in the session via serializeUser, and reconstructs the full req.user on each subsequent request via deserializeUser.
| Piece | What it does |
|---|---|
LocalStrategy | Pulls username and password from the request body |
| Verify callback | Looks up the user and checks the password hash |
serializeUser | Decides what to store in the session (usually just the user ID) |
deserializeUser | Rebuilds req.user from the stored ID on every request |
passport.authenticate | Middleware that runs a strategy on a specific route |
Installing and initializing
Passport relies on an existing session, so express-session must be configured before you initialize Passport.
npm install express express-session passport passport-local bcrypt
// app.js
const express = require('express');
const session = require('express-session');
const passport = require('passport');
require('./auth/passport'); // strategy + serialize config (below)
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use(
session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: 'lax', maxAge: 1000 * 60 * 60 * 24 },
})
);
app.use(passport.initialize());
app.use(passport.session()); // links Passport to req.session — must come after session()
module.exports = app;
Order matters:
passport.session()readsreq.session, so both Passport middlewares must be registered afterexpress-session. Get this wrong andreq.useris silently alwaysundefined.
The verify callback
The verify callback is the heart of the strategy. Passport hands you the submitted credentials and a done callback. You call done(null, user) on success, done(null, false) when credentials are invalid, and done(err) only for unexpected errors (a database outage, not a wrong password).
// auth/passport.js
const passport = require('passport');
const { Strategy: LocalStrategy } = require('passport-local');
const bcrypt = require('bcrypt');
const db = require('../db');
passport.use(
new LocalStrategy(
{ usernameField: 'email' }, // default is 'username'; we log in by email
async (email, password, done) => {
try {
const user = await db.findUserByEmail(email);
// Same generic message whether the email or password is wrong
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return done(null, false, { message: 'Invalid credentials' });
}
return done(null, user); // becomes req.user
} catch (err) {
return done(err); // real failure → error middleware
}
}
)
);
The optional third argument to done is an info object; its message surfaces as info.message in custom callbacks and is handy for flash messaging.
Serialize and deserialize
To keep sessions small, store only the user ID. serializeUser runs once at login; deserializeUser runs on every authenticated request to rehydrate req.user.
// auth/passport.js (continued)
passport.serializeUser((user, done) => {
done(null, user.id); // only the id goes into the session
});
passport.deserializeUser(async (id, done) => {
try {
const user = await db.findUserById(id);
done(null, user || false); // false clears the login if the user was deleted
} catch (err) {
done(err);
}
});
Because deserializeUser hits your data layer on every request, keep that lookup fast — cache it in Redis or memoize hot users if it shows up in your latency profile.
Logging in and out
Mount passport.authenticate('local') as route middleware. On success it populates req.session and calls the next handler; on failure it returns a 401 (or redirects, depending on options).
// routes/auth.js
const express = require('express');
const passport = require('passport');
const router = express.Router();
router.post('/login', passport.authenticate('local'), (req, res) => {
// Only runs after a successful verify
res.json({ id: req.user.id, email: req.user.email });
});
// req.logout requires a callback in Passport 0.6+
router.post('/logout', (req, res, next) => {
req.logout((err) => {
if (err) return next(err);
req.session.destroy(() => {
res.clearCookie('connect.sid');
res.json({ message: 'Logged out' });
});
});
});
module.exports = router;
curl -i -X POST http://localhost:3000/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"correct horse"}'
Output:
HTTP/1.1 200 OK
Set-Cookie: connect.sid=s%3Ae1c8...; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400
Content-Type: application/json; charset=utf-8
{"id":42,"email":"[email protected]"}
Customizing the response
The default authenticate('local') redirects on failure, which suits server-rendered apps but not JSON APIs. Pass a custom callback to control the response yourself.
router.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({ id: user.id, email: user.email });
});
})(req, res, next); // note the immediate invocation with (req, res, next)
});
When you use a custom callback, Passport no longer establishes the session for you — you must call req.login() explicitly.
Protecting routes
Passport adds req.isAuthenticated() to every request. Wrap it in a small guard and apply it to any private route or router.
// middleware/requireAuth.js
function requireAuth(req, res, next) {
if (req.isAuthenticated()) return next();
res.status(401).json({ error: 'Authentication required' });
}
module.exports = requireAuth;
const requireAuth = require('./middleware/requireAuth');
app.get('/me', requireAuth, (req, res) => {
res.json({ id: req.user.id, email: req.user.email, role: req.user.role });
});
Apply router.use(requireAuth) once to protect every route declared below it.
Best Practices
- Register
passport.initialize()andpassport.session()afterexpress-session, orreq.userwill never be set. - Verify passwords with a slow hash like
bcrypt; return the same generic error for unknown users and wrong passwords to avoid leaking which emails exist. - Serialize only the user ID into the session and rebuild the rest in
deserializeUserto keep the cookie payload tiny. - Reserve
done(err)for genuine failures; usedone(null, false)for bad credentials so they don’t hit your error middleware as 500s. - Use a custom
authenticatecallback for JSON APIs and remember to callreq.login()yourself there. - In Passport 0.6+,
req.logout()is asynchronous and requires a callback — pair it withreq.session.destroy()for a clean logout. - For Express 5, async route handlers that throw are forwarded to error middleware automatically; in Express 4 keep calling
next(err)as shown.