Authentication is the gatekeeper of every web application, yet choosing between JWT, OAuth, and sessions confuses many developers. Each approach solves different problems. Sessions offer simplicity and server-side control. JWTs enable stateless authentication across distributed systems. OAuth delegates authentication to trusted providers like Google and GitHub. This guide implements all three in Node.js so you can understand the tradeoffs and pick the right approach for your project.
Session-Based Authentication
Sessions are the oldest and most straightforward approach. The server creates a session record when a user logs in, stores it server-side (in memory, a database, or Redis), and sends the client a session ID cookie. Every subsequent request includes that cookie, and the server looks up the session to identify the user.
npm install express express-session connect-redis redis bcryptjs
// Session-based auth setup
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const bcrypt = require('bcryptjs');
const app = express();
app.use(express.json());
// Redis client for session storage
const redisClient = createClient({ url: 'redis://localhost:6379' });
redisClient.connect();
// Session middleware
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET || 'change-this-secret',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
httpOnly: true, // Prevents JavaScript access
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: 'lax', // CSRF protection
},
}));
// In-memory user store (use a database in production)
const users = [];
// Registration
app.post('/auth/register', async (req, res) => {
const { email, password, name } = req.body;
const hashedPassword = await bcrypt.hash(password, 12);
const user = { id: users.length + 1, email, name, password: hashedPassword };
users.push(user);
res.status(201).json({ message: 'User created' });
});
// Login
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = users.find(u => u.email === email);
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Store user info in session
req.session.userId = user.id;
req.session.email = user.email;
res.json({ message: 'Logged in', user: { id: user.id, name: user.name } });
});
// Logout: destroy the session
app.post('/auth/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' });
});
});
// Session auth middleware
function requireSession(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
next();
}
// Protected route
app.get('/profile', requireSession, (req, res) => {
const user = users.find(u => u.id === req.session.userId);
res.json({ id: user.id, name: user.name, email: user.email });
});
Sessions are ideal for traditional server-rendered applications where the backend and frontend share the same domain. They handle revocation instantly — just delete the session record. The main limitation is scalability: session data must be accessible to every server instance, which is why Redis or a database is used for storage.
JWT (JSON Web Token) Authentication
JWTs are self-contained tokens that encode user information directly. The server signs the token on login, and the client includes it in the Authorization header. The server verifies the signature without looking up any stored state.
npm install jsonwebtoken
// JWT auth implementation
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'access-secret-change-me';
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'refresh-secret-change-me';
// Store refresh tokens (use Redis in production)
const refreshTokens = new Set();
function generateTokens(user) {
const accessToken = jwt.sign(
{ id: user.id, email: user.email, role: user.role },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ id: user.id },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
refreshTokens.add(refreshToken);
return { accessToken, refreshToken };
}
// Login endpoint
app.post('/auth/jwt/login', async (req, res) => {
const { email, password } = req.body;
const user = users.find(u => u.email === email);
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const tokens = generateTokens(user);
res.json(tokens);
});
// Refresh endpoint
app.post('/auth/jwt/refresh', (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken || !refreshTokens.has(refreshToken)) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
const user = users.find(u => u.id === decoded.id);
// Rotate: invalidate old refresh token, issue new pair
refreshTokens.delete(refreshToken);
const tokens = generateTokens(user);
res.json(tokens);
} catch (err) {
refreshTokens.delete(refreshToken);
res.status(403).json({ error: 'Token expired or invalid' });
}
});
// JWT auth middleware
function requireJWT(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access token required' });
}
try {
const token = authHeader.split(' ')[1];
req.user = jwt.verify(token, ACCESS_SECRET);
next();
} catch (err) {
const message = err.name === 'TokenExpiredError' ? 'Token expired' : 'Invalid token';
res.status(401).json({ error: message });
}
}
// Protected route
app.get('/api/data', requireJWT, (req, res) => {
res.json({ message: `Hello ${req.user.email}`, userId: req.user.id });
});
JWTs work well for APIs consumed by mobile apps, SPAs, and microservices. The access token is short-lived (15 minutes), while the refresh token lives longer (7 days) and is used to get new access tokens. Token rotation (issuing a new refresh token each time) prevents stolen refresh tokens from being reused indefinitely.
OAuth 2.0 with Passport.js
OAuth delegates authentication to a third-party provider. Users click “Sign in with Google,” authenticate on Google’s servers, and your application receives a verified profile. This eliminates password management entirely.
npm install passport passport-google-oauth20
// OAuth 2.0 with Google
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) => {
// Find or create user in your database
let user = users.find(u => u.googleId === profile.id);
if (!user) {
user = {
id: users.length + 1,
googleId: profile.id,
name: profile.displayName,
email: profile.emails[0].value,
avatar: profile.photos[0]?.value,
};
users.push(user);
}
return done(null, user);
}
));
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => {
const user = users.find(u => u.id === id);
done(null, user);
});
app.use(passport.initialize());
app.use(passport.session());
// Initiate Google OAuth flow
app.get('/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
// Google callback handler
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => {
// Successful authentication
res.redirect('/dashboard');
}
);
// Check if authenticated via OAuth
function requireOAuth(req, res, next) {
if (!req.isAuthenticated()) {
return res.status(401).json({ error: 'Please sign in with Google' });
}
next();
}
app.get('/dashboard', requireOAuth, (req, res) => {
res.json({
name: req.user.name,
email: req.user.email,
avatar: req.user.avatar,
});
});
Choosing the Right Approach
The decision depends on your architecture and requirements. Here is a practical framework for choosing.
Use sessions when your frontend and backend share the same domain, you need instant revocation (banning users, forced logout), and your architecture is a monolith or small cluster. Sessions are the simplest to implement correctly and the hardest to misuse.
Use JWTs when building APIs consumed by mobile apps or third-party clients, your backend is distributed across multiple services, or you need to embed claims (roles, permissions) in the token itself. Always use short-lived access tokens with refresh token rotation.
Use OAuth when you want to offload identity management to established providers, your users expect social login, or you are building a platform where third-party apps need access to user data. OAuth can be combined with either sessions or JWTs for your own application’s authentication layer.
Many production applications combine approaches. A common pattern uses OAuth for initial sign-in (delegating identity verification to Google or GitHub), creates a server-side session or issues JWTs for subsequent API calls, and stores refresh tokens securely on the server. Security is not about choosing one method — it is about implementing your chosen method correctly, with proper secret management, HTTPS enforcement, and regular token rotation.