Node.js Authentication: Passport.js Strategies
Passport.js has dominated Node.js authentication for over a decade because it solves a fundamental problem: authentication is complex, but it shouldn't be complicated. Instead of building...
Key Insights
- Passport.js uses a modular “strategy” pattern that lets you swap authentication methods without rewriting your entire auth layer—choose from 500+ strategies or build your own
- Local strategy with bcrypt remains the foundation for most apps, but combining it with OAuth and JWT strategies gives users flexibility while maintaining security
- Sessions work great for traditional web apps, but JWT strategies shine in API-first architectures where stateless authentication scales better across microservices
Introduction to Passport.js
Passport.js has dominated Node.js authentication for over a decade because it solves a fundamental problem: authentication is complex, but it shouldn’t be complicated. Instead of building authentication from scratch for each project, Passport provides a consistent interface across different authentication methods through its strategy pattern.
A strategy in Passport is simply a pluggable authentication mechanism. Want username/password auth? Use the Local strategy. Need Google OAuth? There’s a strategy for that. Building a custom API key system? Write your own strategy. This modularity means you can start simple and add authentication methods as your application grows.
Here’s the basic setup in an Express application:
const express = require('express');
const passport = require('passport');
const session = require('express-session');
const app = express();
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
// Session configuration (required for persistent login)
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: process.env.NODE_ENV === 'production' }
}));
// Initialize Passport and restore authentication state from session
app.use(passport.initialize());
app.use(passport.session());
// Serialize user into session
passport.serializeUser((user, done) => {
done(null, user.id);
});
// Deserialize user from session
passport.deserializeUser(async (id, done) => {
try {
const user = await getUserById(id); // Your DB query
done(null, user);
} catch (error) {
done(error);
}
});
The serialization functions are crucial—they determine what data gets stored in the session cookie and how to reconstruct the user object from that data on subsequent requests.
Local Strategy (Username/Password)
The Local strategy handles traditional username and password authentication. Despite the rise of OAuth and passwordless auth, this remains the backbone of most applications because it gives you complete control over the user experience and data.
Never store passwords in plain text. Use bcrypt with a salt round of at least 12:
const LocalStrategy = require('passport-local').Strategy;
const bcrypt = require('bcrypt');
passport.use(new LocalStrategy(
{
usernameField: 'email', // Customize field names
passwordField: 'password'
},
async (email, password, done) => {
try {
const user = await findUserByEmail(email);
if (!user) {
return done(null, false, { message: 'Invalid credentials' });
}
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
if (!isValidPassword) {
return done(null, false, { message: 'Invalid credentials' });
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
// Registration endpoint
app.post('/auth/register', async (req, res) => {
try {
const { email, password } = req.body;
// Validation should happen here
if (!email || !password || password.length < 8) {
return res.status(400).json({ error: 'Invalid input' });
}
const existingUser = await findUserByEmail(email);
if (existingUser) {
return res.status(409).json({ error: 'User already exists' });
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await createUser({ email, passwordHash });
req.login(user, (err) => {
if (err) return res.status(500).json({ error: 'Login failed' });
res.status(201).json({ user: { id: user.id, email: user.email } });
});
} catch (error) {
res.status(500).json({ error: 'Registration failed' });
}
});
// Login endpoint
app.post('/auth/login', passport.authenticate('local'), (req, res) => {
res.json({ user: { id: req.user.id, email: req.user.email } });
});
// Logout endpoint
app.post('/auth/logout', (req, res) => {
req.logout((err) => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.json({ message: 'Logged out successfully' });
});
});
Always use the same generic error message for both “user not found” and “wrong password” to prevent user enumeration attacks.
OAuth Strategies (Google, GitHub)
OAuth strategies delegate authentication to third-party providers. Users get single sign-on convenience, and you avoid storing passwords. The trade-off is dependency on external services and more complex configuration.
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const GitHubStrategy = require('passport-github2').Strategy;
// Google OAuth
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 {
// Check if user exists
let user = await findUserByGoogleId(profile.id);
if (!user) {
// Create new user from Google profile
user = await createUser({
googleId: profile.id,
email: profile.emails[0].value,
displayName: profile.displayName,
avatar: profile.photos[0].value
});
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
// GitHub OAuth
passport.use(new GitHubStrategy({
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: '/auth/github/callback'
},
async (accessToken, refreshToken, profile, done) => {
try {
let user = await findUserByGitHubId(profile.id);
if (!user) {
user = await createUser({
githubId: profile.id,
username: profile.username,
email: profile.emails?.[0]?.value,
avatar: profile.photos[0].value
});
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
// OAuth routes
app.get('/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/dashboard');
}
);
app.get('/auth/github',
passport.authenticate('github', { scope: ['user:email'] })
);
app.get('/auth/github/callback',
passport.authenticate('github', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/dashboard');
}
);
Store OAuth credentials in environment variables and never commit them to version control. Use different credentials for development and production.
JWT Strategy
JWT (JSON Web Token) strategies enable stateless authentication perfect for APIs and microservices. Instead of server-side sessions, the token itself contains the user information, cryptographically signed to prevent tampering.
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
const jwt = require('jsonwebtoken');
// JWT Strategy configuration
passport.use(new JwtStrategy({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
algorithms: ['HS256']
},
async (payload, done) => {
try {
const user = await getUserById(payload.sub);
if (!user) {
return done(null, false);
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
// Token generation helper
function generateToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '7d', algorithm: 'HS256' }
);
}
// Login endpoint that returns JWT
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
try {
const user = await findUserByEmail(email);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = generateToken(user);
res.json({ token, user: { id: user.id, email: user.email } });
} catch (error) {
res.status(500).json({ error: 'Authentication failed' });
}
});
// Protected route middleware
const requireAuth = passport.authenticate('jwt', { session: false });
app.get('/api/profile', requireAuth, (req, res) => {
res.json({ user: req.user });
});
app.get('/api/protected', requireAuth, (req, res) => {
res.json({ message: 'Access granted', userId: req.user.id });
});
Use strong secrets (at least 32 random characters) and consider using RS256 with public/private key pairs for better security in distributed systems. Set reasonable expiration times—7 days for web apps, 1 hour for sensitive operations.
Custom Strategy Development
Sometimes you need authentication that doesn’t fit existing strategies. Creating a custom strategy is straightforward:
const Strategy = require('passport-strategy');
const crypto = require('crypto');
class ApiKeyStrategy extends Strategy {
constructor(options, verify) {
super();
this.name = 'apikey';
this._verify = verify;
this._headerField = options.headerField || 'X-API-Key';
}
authenticate(req) {
const apiKey = req.get(this._headerField);
if (!apiKey) {
return this.fail({ message: 'Missing API key' }, 401);
}
const verified = (err, user, info) => {
if (err) return this.error(err);
if (!user) return this.fail(info);
this.success(user, info);
};
try {
this._verify(apiKey, verified);
} catch (error) {
this.error(error);
}
}
}
// Use the custom strategy
passport.use(new ApiKeyStrategy(
{ headerField: 'X-API-Key' },
async (apiKey, done) => {
try {
const hashedKey = crypto.createHash('sha256').update(apiKey).digest('hex');
const user = await findUserByApiKey(hashedKey);
if (!user) {
return done(null, false, { message: 'Invalid API key' });
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
// Protected API route
app.get('/api/data',
passport.authenticate('apikey', { session: false }),
(req, res) => {
res.json({ data: 'sensitive information' });
}
);
Strategy Comparison & Best Practices
Choose strategies based on your application architecture and user needs. Local strategy works for traditional web apps where you control the entire user experience. OAuth strategies reduce friction for users and offload security concerns, but introduce external dependencies. JWT strategies excel in API-first architectures, mobile apps, and microservices where stateless authentication simplifies scaling.
You can combine multiple strategies on different routes:
// Different strategies for different endpoints
app.post('/auth/login',
passport.authenticate('local', { session: true })
);
app.get('/api/data',
passport.authenticate(['jwt', 'apikey'], { session: false })
);
app.get('/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
// Custom middleware to try multiple strategies
function multiStrategyAuth(strategies) {
return (req, res, next) => {
passport.authenticate(strategies, { session: false }, (err, user) => {
if (err) return next(err);
if (!user) return res.status(401).json({ error: 'Unauthorized' });
req.user = user;
next();
})(req, res, next);
};
}
app.get('/api/flexible',
multiStrategyAuth(['jwt', 'apikey', 'oauth2']),
(req, res) => {
res.json({ message: 'Authenticated via any method' });
}
);
Security best practices: Always use HTTPS in production. Implement rate limiting on authentication endpoints to prevent brute force attacks. Store session secrets and JWT keys in environment variables. Use httpOnly and secure flags on cookies. Implement CSRF protection for session-based auth. Log authentication attempts for security monitoring.
For production deployments, consider using Redis for session storage instead of in-memory sessions, which don’t persist across server restarts or scale horizontally. Implement refresh token rotation for JWTs to balance security and user experience. Always validate and sanitize user input before passing it to strategies.
Passport.js gives you the flexibility to start simple and evolve your authentication as requirements change. Master the Local strategy first, add OAuth for user convenience, then implement JWT when you need stateless API authentication. The strategy pattern ensures you’re never locked into a single approach.