Session vs JWT Authentication in Node.js + MongoDB: What I Actually Ship in Production

Session vs JWT Authentication in Node.js + MongoDB: What I Actually Ship in Production

15 min read

Most auth tutorials stop at "login works".

That is not the real bar.

The real question is this: if an attacker gets one cookie or one token, how fast can you contain damage and revoke access?

This article is a practical guide for backend engineers building Node.js + MongoDB APIs. It covers both session-based auth and JWT-based auth, but with production-grade behavior, not happy-path demos.

You will get:

  • A clear decision framework for Session vs JWT
  • Production-style controller patterns with defensive error handling
  • Real refresh token rotation (not just a mention)
  • Security controls that matter in reviews and interviews

Decision First (For Busy Engineers)

If you only read one section, read this.

ConstraintPrefer Session AuthPrefer JWT Auth
Browser-first monolithYesMaybe
Mobile + web + third-party clientsMaybeYes
Simple server-side revocationYesHarder unless token versioning/rotation is done right
Horizontal scale across many servicesRequires shared session storeYes
Operational simplicityUsually simplerMore moving parts

Opinionated default:

  • If your system is a traditional web app, start with sessions.
  • If your system is API-heavy and multi-client, use JWT with rotating refresh tokens.

Auth Lifecycle Caption: "Full request lifecycle — session auth (left) vs JWT rotation flow (right)"

Threat Model and Non-Negotiables

Before code, define what you are defending against:

  • Credential stuffing against login endpoints
  • Stolen refresh token from a compromised client environment
  • Session fixation during login
  • Token replay after logout/password reset
  • Weak observability that hides auth abuse patterns

Non-negotiables in this article:

  1. Password hashing with bcrypt
  2. HTTP-only cookies for session ID or refresh token
  3. Strict token/session invalidation rules
  4. Consistent try/catch and centralized error handling
  5. Rotation of refresh tokens on every refresh call

Stack

  • Node.js
  • Express.js
  • MongoDB + Mongoose
  • bcrypt
  • cookie-parser
  • express-session + connect-mongo
  • jsonwebtoken

Install:

npm install express mongoose dotenv bcrypt cookie-parser cors
npm install express-session connect-mongo jsonwebtoken

Environment:

PORT=4000
MONGODB_URL=mongodb://localhost:27017/auth_db
SESSION_SECRET=replace_with_long_random_value
ACCESS_JWT_SECRET=replace_with_long_random_value
REFRESH_JWT_SECRET=replace_with_long_random_value
NODE_ENV=development

Shared Foundation (Used by Both Approaches)

Mongo Connection

// connection.js
import mongoose from "mongoose";

export const connectDB = async (mongoUrl) => {
  await mongoose.connect(mongoUrl);
  console.log("MongoDB connected");
};

User Model (Production-Oriented)

Store a hash of refresh token, not the raw token.

// models/user.model.js
import { Schema, model } from "mongoose";

const userSchema = new Schema(
  {
    name: { type: String, required: true },
    email: { type: String, required: true, unique: true, index: true },
    password: { type: String, required: true },
    refreshTokenHash: { type: String, default: null },
    tokenVersion: { type: Number, default: 0 },
  },
  { timestamps: true },
);

export const User = model("User", userSchema);

Centralized Error Pattern

Most auth bugs come from inconsistent error branches. Start with a standard wrapper.

// utils/errors.js
export class AppError extends Error {
  constructor(statusCode, message) {
    super(message);
    this.statusCode = statusCode;
  }
}

export const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

// index.js
app.use((err, req, res, next) => {
  const status = err.statusCode || 500;
  const message = status >= 500 ? "Internal Server Error" : err.message;

  return res.status(status).json({
    success: false,
    error: {
      message,
      code: status,
    },
  });
});

Registration Controller

// controllers/auth.common.controller.js
import bcrypt from "bcrypt";
import { User } from "../models/user.model.js";
import { AppError, asyncHandler } from "../utils/errors.js";

export const registerUser = asyncHandler(async (req, res) => {
  const { name, email, password } = req.body;

  if (!name || !email || !password) {
    throw new AppError(400, "name, email, and password are required");
  }

  const existing = await User.findOne({ email });
  if (existing) {
    throw new AppError(409, "user already exists");
  }

  const hashedPassword = await bcrypt.hash(password, 12);
  const user = await User.create({ name, email, password: hashedPassword });

  return res.status(201).json({
    success: true,
    data: { userId: user._id.toString() },
  });
});

Session-Based Auth (Stateful)

Session auth is stateful: server stores session data, client stores only a session ID cookie.

Express Session Setup

// index.js
import session from "express-session";
import MongoStore from "connect-mongo";

app.set("trust proxy", 1); // required when deployed behind reverse proxy

app.use(
  session({
    name: "sid",
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    rolling: true,
    store: MongoStore.create({
      mongoUrl: process.env.MONGODB_URL,
      collectionName: "sessions",
      ttl: 60 * 60 * 24 * 7,
    }),
    cookie: {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax",
      maxAge: 1000 * 60 * 60 * 24 * 7,
      path: "/",
    },
  }),
);

Login With Session Regeneration (Fixes Session Fixation)

// controllers/auth.session.controller.js
import bcrypt from "bcrypt";
import { User } from "../models/user.model.js";
import { AppError, asyncHandler } from "../utils/errors.js";

export const loginWithSession = asyncHandler(async (req, res) => {
  const { email, password } = req.body;

  const user = await User.findOne({ email });
  if (!user) throw new AppError(401, "invalid credentials");

  const isValid = await bcrypt.compare(password, user.password);
  if (!isValid) throw new AppError(401, "invalid credentials");

  await new Promise((resolve, reject) => {
    req.session.regenerate((err) => (err ? reject(err) : resolve()));
  });

  req.session.userId = user._id.toString();
  req.session.authMethod = "session";

  return res.status(200).json({
    success: true,
    message: "login successful",
  });
});

Session Middleware + Logout

// middlewares/session-auth.middleware.js
import { User } from "../models/user.model.js";

export const sessionAuthMiddleware = async (req, res, next) => {
  try {
    const userId = req.session?.userId;
    if (!userId) {
      return res.status(401).json({ success: false, message: "unauthorized" });
    }

    const user = await User.findById(userId).select(
      "-password -refreshTokenHash",
    );
    if (!user) {
      return res.status(401).json({ success: false, message: "unauthorized" });
    }

    req.user = user;
    next();
  } catch {
    return res
      .status(500)
      .json({ success: false, message: "session check failed" });
  }
};

// controllers/auth.session.controller.js
export const logoutSessionUser = (req, res) => {
  req.session.destroy((err) => {
    if (err) {
      return res.status(500).json({ success: false, message: "logout failed" });
    }

    res.clearCookie("sid", {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax",
      path: "/",
    });

    return res
      .status(200)
      .json({ success: true, message: "logout successful" });
  });
};

Where Session Auth Wins

  • Easy invalidation from server side
  • Great fit for browser-first apps
  • Simpler lifecycle than JWT + refresh flow

Where Session Auth Hurts

  • Requires session store availability
  • Added state management in distributed systems

JWT Auth With Rotating Refresh Tokens (Hybrid)

JWT access token is short-lived and sent in Authorization header. Refresh token stays in an HTTP-only cookie and rotates on every refresh.

Token Utilities

// utils/token.js
import jwt from "jsonwebtoken";
import { createHash, randomUUID } from "crypto";

export const hashToken = (token) =>
  createHash("sha256").update(token).digest("hex");

export const buildTokenPayload = (user) => ({
  sub: user._id.toString(),
  tv: user.tokenVersion,
  jti: randomUUID(),
});

export const signAccessToken = (payload) =>
  jwt.sign(payload, process.env.ACCESS_JWT_SECRET, {
    expiresIn: "15m",
    issuer: "auth-service",
    audience: "api",
  });

export const signRefreshToken = (payload) =>
  jwt.sign(payload, process.env.REFRESH_JWT_SECRET, {
    expiresIn: "7d",
    issuer: "auth-service",
    audience: "refresh",
  });
// utils/cookies.js
export const refreshCookieOptions = {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "strict",
  path: "/auth",
  maxAge: 1000 * 60 * 60 * 24 * 7,
};

JWT Login Controller

// controllers/auth.jwt.controller.js
import bcrypt from "bcrypt";
import { User } from "../models/user.model.js";
import { AppError, asyncHandler } from "../utils/errors.js";
import {
  buildTokenPayload,
  hashToken,
  signAccessToken,
  signRefreshToken,
} from "../utils/token.js";
import { refreshCookieOptions } from "../utils/cookies.js";

export const loginWithJWT = asyncHandler(async (req, res) => {
  const { email, password } = req.body;

  const user = await User.findOne({ email });
  if (!user) throw new AppError(401, "invalid credentials");

  const isValid = await bcrypt.compare(password, user.password);
  if (!isValid) throw new AppError(401, "invalid credentials");

  const payload = buildTokenPayload(user);
  const accessToken = signAccessToken(payload);
  const refreshToken = signRefreshToken(payload);

  user.refreshTokenHash = hashToken(refreshToken);
  await user.save();

  res.cookie("refreshToken", refreshToken, refreshCookieOptions);

  return res.status(200).json({
    success: true,
    data: { accessToken },
  });
});

Refresh Endpoint With Real Rotation and Reuse Detection

This is the part most tutorials skip.

// controllers/auth.jwt.controller.js
import jwt from "jsonwebtoken";

export const refreshAccessToken = asyncHandler(async (req, res) => {
  const incomingRefreshToken = req.cookies.refreshToken;
  if (!incomingRefreshToken) throw new AppError(401, "missing refresh token");

  let decoded;
  try {
    decoded = jwt.verify(incomingRefreshToken, process.env.REFRESH_JWT_SECRET, {
      issuer: "auth-service",
      audience: "refresh",
    });
  } catch {
    throw new AppError(401, "invalid refresh token");
  }

  const user = await User.findById(decoded.sub);
  if (!user) throw new AppError(401, "invalid refresh token");

  const incomingHash = hashToken(incomingRefreshToken);

  // Reuse or mismatch: revoke chain by bumping tokenVersion.
  if (
    !user.refreshTokenHash ||
    user.refreshTokenHash !== incomingHash ||
    decoded.tv !== user.tokenVersion
  ) {
    user.refreshTokenHash = null;
    user.tokenVersion += 1;
    await user.save();
    res.clearCookie("refreshToken", refreshCookieOptions);
    throw new AppError(401, "refresh token reuse detected");
  }

  // Rotation: issue fresh pair and replace stored hash.
  const payload = buildTokenPayload(user);
  const newAccessToken = signAccessToken(payload);
  const newRefreshToken = signRefreshToken(payload);

  user.refreshTokenHash = hashToken(newRefreshToken);
  await user.save();

  res.cookie("refreshToken", newRefreshToken, refreshCookieOptions);

  return res.status(200).json({
    success: true,
    data: { accessToken: newAccessToken },
  });
});

JWT Middleware + Logout

// middlewares/jwt-auth.middleware.js
import jwt from "jsonwebtoken";
import { User } from "../models/user.model.js";

export const jwtAuthMiddleware = async (req, res, next) => {
  try {
    const authHeader = req.headers.authorization;
    if (!authHeader || !authHeader.startsWith("Bearer ")) {
      return res.status(401).json({ success: false, message: "unauthorized" });
    }

    const accessToken = authHeader.split(" ")[1];
    const decoded = jwt.verify(accessToken, process.env.ACCESS_JWT_SECRET, {
      issuer: "auth-service",
      audience: "api",
    });

    const user = await User.findById(decoded.sub).select(
      "-password -refreshTokenHash",
    );
    if (!user || decoded.tv !== user.tokenVersion) {
      return res.status(401).json({ success: false, message: "unauthorized" });
    }

    req.user = user;
    next();
  } catch {
    return res.status(401).json({ success: false, message: "unauthorized" });
  }
};

// controllers/auth.jwt.controller.js
export const logoutJWTUser = asyncHandler(async (req, res) => {
  const token = req.cookies.refreshToken;

  if (token) {
    const user = await User.findOne({ refreshTokenHash: hashToken(token) });
    if (user) {
      await User.findByIdAndUpdate(user._id, {
        refreshTokenHash: null,
      });
    }
  }

  res.clearCookie("refreshToken", refreshCookieOptions);

  return res.status(200).json({ success: true, message: "logout successful" });
});

Where JWT Wins

  • Strong fit for API ecosystems and multiple client types
  • Better service-level scaling characteristics
  • Access token can be validated quickly at edge/services

Where JWT Hurts

  • More failure modes than session auth
  • Rotation, replay handling, and revocation are easy to get wrong

API Shape (Keep It Predictable)

Session routes:

  • POST /auth/register
  • POST /auth/login
  • DELETE /auth/logout
  • GET /auth/me

JWT routes:

  • POST /auth/register
  • POST /auth/login
  • POST /auth/refresh
  • DELETE /auth/logout
  • GET /auth/me

Security Gaps to Avoid (Blunt Version)

If your blog or code does these, reviewers will catch it:

  1. Storing raw refresh token in DB instead of hash
  2. Refresh endpoint that returns only access token without rotating refresh token
  3. No session regeneration after login (session fixation risk)
  4. No rate limiting on auth endpoints
  5. No CSRF strategy while using cookie-based refresh/session endpoints
  6. Different cookie options between setCookie and clearCookie
  7. Generic 500 responses that leak stack traces in production

For cross-site SPAs where SameSite=None is required, add CSRF token validation on state-changing endpoints.


What Makes This Portfolio Piece Stronger for Hiring Reviews

If you want senior engineers to take this seriously, include these extras in your repo:

  1. Integration tests for login, refresh rotation, token reuse, logout
  2. Rate limiting middleware example on /auth/login and /auth/refresh
  3. Sequence diagram for session flow and JWT rotation flow
  4. A short "failure mode" section describing compromise scenarios and response
  5. Structured auth logs with request ID and user ID (when available)

That signals engineering judgment, not just framework familiarity.


Final Verdict (Opinionated)

The wrong question is "Which is better, Session or JWT?" The right question is "Which failure mode can my team operate safely?"

My rule:

  • Choose sessions when you want simplicity and tight server control.
  • Choose JWT only if you also implement rotation, replay defense, and revocation mechanics from day one.

A simple, correctly operated session system beats a half-implemented JWT system every time.


Quick Recap

  • Session auth: simpler lifecycle, stateful revocation, excellent for browser-first apps
  • JWT auth: scalable and flexible, but requires stronger discipline
  • Production readiness is mostly about handling compromise and invalidation, not just issuing tokens

If your auth design can survive token theft, replay attempts, and forced logout requirements, you are building it correctly.

FIG. 02

Taksh Patel
Taksh Patel

Creating with code. Shipping the honest version.

© 2026 Taksh Patel. All rights reserved.