Authentication Strategies Compared
There are three primary authentication approaches for Node.js applications, each suited to different architectures.
Session-Based Authentication
Session auth stores user state on the server. The client only holds a session ID in a cookie.
Setup with Express
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const bcrypt = require('bcrypt');
const app = express();
// Redis client for session storage
const redisClient = createClient({ url: 'redis://localhost:6379' });
redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS only
httpOnly: true, // Not accessible via JavaScript
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: 'strict', // CSRF protection
},
}));Login and Logout
// Login
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Store user info in session
req.session.userId = user.id;
req.session.role = user.role;
res.json({ message: 'Logged in', user: { id: user.id, name: user.name } });
});
// Auth middleware
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
next();
}
// Logout — destroy session
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.clearCookie('connect.sid');
res.json({ message: 'Logged out' });
});
});
// Protected route
app.get('/api/profile', requireAuth, async (req, res) => {
const user = await db.users.findById(req.session.userId);
res.json(user);
});JWT Authentication
JWTs are stateless tokens that encode user claims. The server doesn’t need to store session data.
Implementing JWT Auth
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
// Generate token pair
function generateTokens(user) {
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ sub: user.id, tokenVersion: user.tokenVersion },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
}
// Login endpoint
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const tokens = generateTokens(user);
// Store refresh token hash in DB (for revocation)
const refreshHash = await bcrypt.hash(tokens.refreshToken, 10);
await db.refreshTokens.create({
userId: user.id,
tokenHash: refreshHash,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
// Set refresh token as httpOnly cookie
res.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken: tokens.accessToken });
});JWT Middleware
function authenticateJWT(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, ACCESS_SECRET);
req.user = { id: payload.sub, role: payload.role };
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(403).json({ error: 'Invalid token' });
}
}
// Role-based authorization
function requireRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Protected routes
app.get('/api/users', authenticateJWT, requireRole('admin'), getUsers);
app.get('/api/profile', authenticateJWT, getProfile);Token Refresh with Rotation
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
const payload = jwt.verify(refreshToken, REFRESH_SECRET);
// Find stored token and verify
const storedToken = await db.refreshTokens.findByUserId(payload.sub);
if (!storedToken) {
return res.status(401).json({ error: 'Token revoked' });
}
const isValid = await bcrypt.compare(refreshToken, storedToken.tokenHash);
if (!isValid) {
// Possible token theft — revoke all tokens for this user
await db.refreshTokens.deleteAllForUser(payload.sub);
return res.status(401).json({ error: 'Token reuse detected' });
}
// Rotate: delete old token, issue new pair
await db.refreshTokens.delete(storedToken.id);
const user = await db.users.findById(payload.sub);
const tokens = generateTokens(user);
const newHash = await bcrypt.hash(tokens.refreshToken, 10);
await db.refreshTokens.create({
userId: user.id,
tokenHash: newHash,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
res.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken: tokens.accessToken });
} catch (err) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
});OAuth 2.0 with Passport.js
OAuth lets users log in with third-party providers like Google or GitHub.
Google OAuth Setup
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
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 {
// Find or create user
let user = await db.users.findByProviderId('google', profile.id);
if (!user) {
user = await db.users.create({
name: profile.displayName,
email: profile.emails[0].value,
provider: 'google',
providerId: profile.id,
avatar: profile.photos[0]?.value,
});
}
done(null, user);
} catch (err) {
done(err);
}
}
));
// Routes
app.get('/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
app.get('/auth/google/callback',
passport.authenticate('google', { session: false }),
(req, res) => {
// Generate JWT for the authenticated user
const tokens = generateTokens(req.user);
res.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
// Redirect to frontend with access token
res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${tokens.accessToken}`);
}
);Password Hashing with bcrypt
Never store plain-text passwords. Always hash with bcrypt.
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12;
// Registration
app.post('/auth/register', async (req, res) => {
const { name, email, password } = req.body;
// Check for existing user
const existing = await db.users.findByEmail(email);
if (existing) {
return res.status(409).json({ error: 'Email already registered' });
}
// Hash password (salt is embedded in the hash)
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
const user = await db.users.create({
name,
email,
passwordHash,
});
const tokens = generateTokens(user);
res.status(201).json({ accessToken: tokens.accessToken });
});Security Best Practices Checklist
// 1. Rate limit auth endpoints
const rateLimit = require('express-rate-limit');
app.use('/auth/', rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // 10 attempts per 15 minutes
message: { error: 'Too many attempts, try again later' },
}));
// 2. Use helmet for security headers
const helmet = require('helmet');
app.use(helmet());
// 3. Prevent timing attacks on login
// bcrypt.compare is already constant-time
// 4. Validate input
const { z } = require('zod');
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
});
// 5. CSRF protection for cookie-based auth
const csrf = require('csurf');
app.use(csrf({ cookie: true }));When to Use Each Approach
| Approach | Best For | Avoid When |
|---|---|---|
| Sessions | Server-rendered apps, simple SPAs | Microservices, mobile APIs |
| JWT | Stateless APIs, microservices, mobile | You need instant revocation |
| OAuth | Third-party login, social auth | Internal-only systems |
For most production APIs, the sweet spot is JWT with refresh token rotation for your own auth, plus OAuth as an alternative login method. Store refresh tokens in httpOnly cookies and keep access tokens short-lived (15 minutes).
