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
millisecondsexpires: 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 truehttpOnly: (optional but recommended) Prevents JS from reading cookies
sameSite: we discuss this in depth below.
Note: If both
expires
andmaxAge
are set in the options, then the last one defined in the object is what is used. Theexpires
option should not be set directly; instead only use themaxAge
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
, ordisabled
)
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:
Setting | What It Does | CSRF Protection |
sameSite=Strict | Only sends cookies for same-site requests | Very secure |
sameSite=Lax | Sends cookies for top-level GET requests (e.g., clicking a link), but not for cross-site POSTs | Protects most cases |
sameSite=None | Sends 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
, andThe
session
cookie hasSameSite: '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 tomysite.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
, orDELETE
from another site.This mitigates CSRF attacks, like the one where a malicious site tries to submit a form (
POST
) tohttps://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 returnfalse
secure: true
cookies won’t be set
req.ip
might show proxy IP instead of the user's
Subscribe to my newsletter
Read articles from Abhinab Choudhury directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
