nodejs|April 02, 2026|5 min read

Authentication — JWT, Sessions, OAuth in Node.js

TL;DR

Use JWT for stateless APIs (short-lived access + long-lived refresh tokens). Use sessions for server-rendered apps. Use OAuth for third-party login. Always hash passwords with bcrypt, store refresh tokens securely, and implement proper token rotation.

Authentication Strategies Compared

There are three primary authentication approaches for Node.js applications, each suited to different architectures.

Session vs JWT Comparison

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.

JWT Authentication Flow

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.

OAuth 2.0 Flow

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).

Related Posts

Latest Posts