Implementing a Session-Based Authentication using Node and Express.js

We set up a basic Typescript Project. You can do it from scratch, but I have prepared a shell script that does that for me. Feel free to use, btw, this shell script, which I am using below to create the project, was created and documented in this blog

curl -sL https://gist.githubusercontent.com/abhinab-choudhury/8aea1207c79f25b80b65c76a0b684cbe/raw/53a59d9ffdff9f59952f83d2db181d11c480f250/generate_express_app.sh | bash -s server pnpm

Now let’s configure, first of all, we set up our session, before starting the packages that we must install in our express-server are express-session, cors, connect-mongo, and mongoose

npm install express express-session cors connect-mongo mongoose bcryptjs
npm install --save-dev @types/cors @types/express @types/express-session

A brief overview of why we need these packages

  • express-session: Manages user login state across requests. Without it, the server can't "remember" who the user is between requests.

  • mongoose: Used to define and interact with your user model (email, password) and store user data in MongoDB.

  • connect-mongo: Stores session data from express-session in MongoDB instead of memory.

  • cors: Handles Cross-Origin Resource Sharing, allowing your frontend (on a different domain or port) to talk to your backend.

  • bcryptjs: Hashes and compares passwords securely.

How to configure Express-session

We will be adding the express-session as a middleware with these configs

app.use(session({
  secret: SESSION_SECRET,
  resave: false,
  saveUninitialized: false, // better for security
  rolling: true, // refresh expiration on activity
  cookie: {
    maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week
    sameSite: 'lax', // good default for auth forms
    secure: true, // only send over HTTPS
    httpOnly: true // helps prevent XSS access
  },
  store: MongoStore.create({ 
    mongoUrl: DATABASE_URL, 
    collectionName: "sessions", // customize collection name
    ttl: 14 * 24 * 60 * 60, // Time-to-live in seconds (default: 14 days)
    autoRemove: "native", // Automatically remove expired sessions
  }),
}));

What does these options do?

  • secret: Express session uses this secret to generate a secret for every session

  • resave: A boolean type parameter which

  • saveUninitialized: Avoids creating sessions for unauthenticated users

  • rolling: Refreshes session expiration on activity when set to true

  • cookie:

    • maxAge: Defines how long a session is valid in ms milliseconds

    • expires: By default, no expiration is set, and most clients will consider this a "non-persistent cookie" and will delete it under a condition like exiting a web browser application.

    • secure: sends cookie only over HTTPS — essential in production when set to true, so when it’s true, during development your cookies won't be set, so for development set this to false, and when in production set this to true

    • httpOnly: (optional but recommended) Prevents JS from reading cookies

    • sameSite: we discuss this in depth below.

Note: If both expires and maxAge are set in the options, then the last one defined in the object is what is used. The expires option should not be set directly; instead only use the maxAge option

  • store: tells express-session where to persist sessions (instead of keeping them in memory, which is not suitable for production).

    • mongoUrl: MongoDB connection string (required)

    • collectionName: The MongoDB collection name for sessions (default is sessions)

    • ttl: Time-to-live in seconds, how long sessions last in the DB (default: 14 days)

    • autoRemove: Controls auto-cleanup of expired sessions (native, interval, or disabled)

I did mention how these options work, but it is very important to know when to use them and why we use them. The most confusing part for most people is sameSite option in cookie, What does this option do? The primary object is to mitigate Cross-Site Request Forgery (CSRF) attacks by restricting when cookies are automatically sent in cross-origin requests.

What are Cross-Site Request Forgery (CSRF) attacks?

CSRF (Cross-Site Request Forgery) is an attack where a malicious website tricks a user's browser into sending unauthorized, yet authenticated requests to a different site, typically one where the user is already logged in.

How it works

When you're authenticated on a site like bank.com, your browser stores a session cookie (e.g., connect.sid) that is automatically included in every request to bank.com.

A malicious site (e.g., evil.com) can exploit this by silently triggering a request from your browser:

<img src="https://bank.com/transfer?to=hacker&amount=1000">

Since you're already authenticated (via cookies), the session secret connect.sid will automatically get sent to the target server every time you make a request, so the server is tricked as it considers the request to be legitimate, as the session secret sent via the cookie to the server is authenticated and can perform different operations, which can only be performed when the user is authenticated.

Note: The server has no way to distinguish whether you triggered the action intentionally or whether it came from another site — unless proper protections are in place.

Real-World Mitigation

We achieve this through the proper configuration of sameSite cookie attribute:

SettingWhat It DoesCSRF Protection
sameSite=StrictOnly sends cookies for same-site requestsVery secure
sameSite=LaxSends cookies for top-level GET requests (e.g., clicking a link), but not for cross-site POSTsProtects most cases
sameSite=NoneSends cookies for all requests (must be Secure)Vulnerable (requires extra CSRF tokens)
Why SameSite: 'lax' is an Ideal Choice for Most Use Cases

Imagine a site that uses session-based authentication, such as https://mysite.com, where users must log in to access protected resources.

Now, say a user is reading an article on another blog (https://someblog.com) that links to a protected route on mysite.com — for example:

<a href="https://mysite.com/dashboard">Go to Dashboard</a>

If:

  • The user is already logged in mysite.com, and

  • The session cookie has SameSite: 'strict' Then: Clicking that link will not send the session cookie, because even though it’s a top-level navigation, it originated from another site. So the user will appear unauthenticated to mysite.com.

That’s where SameSite: 'lax' becomes useful:

  • It allows cookies to be sent on top-level, safe navigations, such as clicking a link (GET requests), even from another site.

  • This means your session cookie will be sent, and the user will be authenticated as expected. However:

  • It blocks cookies on unsafe cross-origin requests, like POST, PUT, or DELETE from another site.

  • This mitigates CSRF attacks, like the one where a malicious site tries to submit a form (POST) to https://mysite.com/transfer.

Note: user these command to generate secrets

openssl rand -base64 32   # for linux users
For Windows users
[convert]::ToBase64String((1..32 | ForEach-Object {Get-Random -Maximum 256}))
$bytes =[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)
[Convert]::ToBase64String($bytes)
Let’s Implement Auth with Session-Based Tokens

This will not be a line-by-line guide, but rather a general-purpose one.

When the user hits the login route
import { Router } from "express";
const router = Router();

router.post("/auth/login", loginController);
// loginController.js
export function loginController(req, res) {
  try {
    // validate the data sent by the user 
    // check if the user with the given credentials already exists
    const user = User.findOne({ 
        where: { 
            email: req.body.email 
        } 
    }); // email is unique
    if (user) {

      // manually set a session
      req.session.userId = user["_id"];
      // ---------------------- or --------------------
      req.logIn(user, function (err) {
        if (err) {
          return next(new ApiError(500, "Login failed", [err.message]));
        }
        return res
          .status(200)
          .json(new ApiResponse(200, "User logged in successfully", true));
      });  
      // -----------------------------------------------

      res.status(200).json({ message: "Logged in successfully" });
    } else {
      res.status(400).json({ message: "Invalid credentials" });
    }
  } catch (error) {
    console.error('Server Error:', error);
    res.status(500).json({ message: "Internal server error" });
  }
}
How do we validate the logged-in state?

We implement a middleware:

// middleware.js
export function isAuthenticated(req, res, next) {
  // check if the request contains a session along with userId 
  if (!req.session.userId) {
    return res.status(401).json({ message: "Unauthorized" });
  }
  next();
}

If you decided to use req.logIn() Here is an alternative auth middleware implementation

// middleware.js
export const isAuthenticated = function (req,res,next) {
  if (req.isAuthenticated()) {
    return next();
  }
  next(new ApiError(401, "Unauthorized"));
};

Apply this middleware to protected routes

router.post('/create', isAuthenticated, createController);
How do we log the user out?

We simply destroy the session. Although deleting the session from the server alone is sufficient, it’s a good practice to also remove the session cookie from the client.

router.post('/logout', logoutController);
// logoutController.js
export function logoutController(req, res) {
  req.session.destroy((err) => {
    if (err) {
      console.error("Session destroy error:", err);
      return res.status(500).json({ message: "Internal server error" });
    }
    // Optionally clear the cookie on the client
    res.clearCookie("connect.sid");
    return res.status(200).json({ message: "Logged out successfully" });
  });
}

Here is another implementation with req.logOut()

/*
 * req.logout() logs the user out of Passport's internal state.
 * req.session.destroy() deletes their session from the session store.
 * res.clearCookie() deletes the session cookie on the client.
 */
export function logoutController(req, res) {
  req.logOut(function(err) {
    if(err) {
      throw new ApiError(400, "Failed to logout", err);
    }
  })
  req.session.destroy((err) => {
    if(err) {
      throw new ApiError(500, "Failed to destroy the session", err);
    }
    res.clearCookie("connect.sid")
    return res.status(200).json(new ApiResponse(200, "User logged out", true));
  })
}
OAuth with Session-Based Auth

Let’s quickly cover how to handle login using OAuth providers like Google. When a user logs in via Google OAuth, Google typically returns an access_token. In token-based systems, this is sent to the client for subsequent API requests.

But in session-based systems, you don’t need to send this token to the client. Instead, you:

req.session.userId = user._id; // store the user ID in the session

Or we can use req.logIn()

req.logIn(user, function (err) {
  if (err)
    return reject(new ApiError(500, "Login failed", [err.message]));
  return resolve();
});

So, if you are using packages like passport.js, this will be a lot easier because passport.js automatically handles the session and populates the session according.

This way, the session is used to maintain login state, and the client doesn't need to know about or handle the access token directly. Here is a project that i built implementing session-based auth systems, which you can refer to for any further queries.

Note: If you are planning to into a production and you decided to use Vercel, Render,Heroku use app.set("trust proxy", 1) This tells Express to trust the first proxy (like Vercel, Heroku, Nginx) in the request chain. When using a reverse proxy (like Vercel or Nginx), requests may reach Express over plain HTTP, even if the user connected via HTTPS.

Without this setting

  • req.secure will return false

  • secure: true cookies won’t be set

  • req.ip might show proxy IP instead of the user's

0
Subscribe to my newsletter

Read articles from Abhinab Choudhury directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Abhinab Choudhury
Abhinab Choudhury