Custom Development

Securing Node.js: Managing Sessions in Express.js

Max McCarty

This post was also published on LockMeDown.com, a security-focused blog for developers written by Max McCarty.

Today’s web applications are powered by the dynamic user experience. But in great contrast, the HTTP protocol powering those same applications is a stateless protocol. What does that mean?  Every user interaction with your application is an isolated and individual request and response. The need to persist information between requests is vital for maintaining the ultimate experience for the user. The same applies for Node.js web applications, such as the popular Express.js framework.

Most developers are familiar with the need to maintain that a user has authenticated with an application. In addition, it's quite common to retain various personalized user information that is associated with a session as well.  

In the last Securing Node.js article we looked at enforcing requirements on user accounts. Similarly, we are going to look at how we can securely setup sessions in our application to mitigate risks, such as session hijacking. We’re going to look at how we can obfuscate session IDs, enforce a time-to-live on our sessions, set up secure cookies for transporting sessions, and finally, cover the importance and role of Transport Layer Security (TLS) when it comes to using sessions.

 

Setting of Sessions in Express.js

We’re going to use the NPM module express-sessions, a very popular session module that has been highly vetted by the community and constantly improved.  

We’ll pass our express app object to a function to wire up the express-session module:

"use strict";

// provides a promise to a mongodb connection

import connectionProvider            from "../data_access/connectionProvider";

// provides application details such as MongoDB URL and DB name

import {serverSettings}                  from "../settings";

import session                               from "express-session";

import mongoStoreFactory            from "connect-mongo";


export default function sessionManagementConfig(app) {


   // persistence store of our session

   const MongoStore = mongoStoreFactory(session);


   app.use(session({

       store: new MongoStore({

           dbPromise: connectionProvider(serverSettings.serverUrl, serverSettings.database)

       }),

       secret: serverSettings.session.password,

       saveUninitialized: true,

       resave: false,

       cookie: {

           path: "/",

       }

   }));


session.Session.prototype.login = function(user, cb){

       this.userInfo = user;

       cb();

   };

}

 

What’s going on here?

  1. Importing the session function from the express-session NPM module
  2. Passing the session function a configuration object to set properties, such as:
    1. Store: I’m using MongoDB as my backend, and I want to persist the application sessions in my database. So I am using the connect-mongo NPM module and setting the session store value to an instance of this module. However, you might be using a different backend, so your store option could be different. The default for express-session is an in-memory storage.
    2. Secret: This is a value used in the signing of the session ID that is stored in the cookie.
    3. Cookie: Determines the behavior of the HTTP cookie that stores the session ID.

We will come back to some of the elements I didn’t mention here shortly.  For now, let's look at the first first change we need to make with securely managing user sessions in our application.

Session Hijacking: The most prevalent risk that user sessions face is session hijacking.  Sessions are much like a driver's license or passport and provide identification for our users. If an attacker can steal the session of another user, they have essentially become that other person and can exploit the user or perform malicious activity on behalf of that user. The risk is even greater when the identity is someone with escalated privileges, such as a site admin.

 

Session Identification

The first step that any attacker will perform is reconnaissance to determine where vulnerabilities lie in your application. Part of that reconnaissance is observing tell-tale signs of the underlying framework, third-party modules and any other software that in itself might contain vulnerabilities that can be exploited.

In the case of our sessions, a tell-tale sign is the name of the session cookie “connect.sid”, which can help an attacker identify the session mechanism being used and look for specific vulnerabilities.  

session-identification.png

TIP: Of course we don’t want to use vulnerable software. But today’s secure software could be vulnerable tomorrow with a faulty update.  So keeping details about our application obfuscated can help make it that much more difficult for an attacker to exploit.

Therefore, the first thing we want to do is make it as hard to determine what session mechanism is used as possible. Let's update our session configuration object with a name property:

app.use(session({

       store: new MongoStore({

           dbPromise: connectionProvider(serverSettings.serverUrl, serverSettings.database)

       }),

       secret: serverSettings.session.password,

       saveUninitialized: true,

       resave: false,

       cookie: {

           path: "/",

       }

   

      name: "id"

   }));

 

What did we do here?

Providing a name property with a value of “id” makes it that much more difficult for any attacker to determine the underlying mechanisms used by our application.  

Now that we have provided a level of obfuscation to our sessions, let's look at how we can reduce the window of opportunity for session hijacking.

 

Session’s Time-to-Live

Unfortunately, sometimes the best laid plans can be undermined.  A perfect example is the user who didn’t log off a public computer and an attacker who was able to physically obtain access and operate as the previous user.  

Therefore, we can reduce the window that the session is alive and directly impact the chances that an attacker can exploit a user or the system from a hijacked session by limiting the life of a session.

As I mentioned before, the express-sessions NPM module provides a store property where you can set a separate storage mechanism for storing your sessions (the default is in-memory). Therefore, the following change is tied to your backend storage of sessions. In my case, I am storing my sessions in a MongoDB database, and using the NPM module connect-mongo for easily storing sessions in the database.  

In this case, I can provide the ttl property a value in seconds in the configuration object provided to the MongoStore. The default is 14 days (14*24*60*60):

app.use(session({

  store: new MongoStore({

      dbPromise: connectionProvider(serverSettings.serverUrl, serverSettings.database),

      ttl: (1 * 60 * 60)

  }),

  //remaining - removed for brevity

}));


 

Cookie Time-to-Live

It’s not nearly as important as the session’s TTL, but we can also set the expiration of the cookie, which is used for transporting the session ID, in the session configuration object.

We can provide a maxAge property and value in milliseconds in the cookie object.

app.use(session({

      //..previous removed for brevity


       cookie: {

           path: "/“,

       maxAge:  1800000  //30 mins

       }

}));

 

However, security and user experience is always a balancing act and in this case, reducing the time-to-live of the session will directly affect the user experience (when the user will be required to re-authenticate).

TIP: The most important is the life of the session, so whether you set a cookie’s age, you should never rely on it by itself and should always regulate the session’s time-to-live. One way to counterbalance the user’s experience is to require re-authenticating at the time the user attempts to access key access areas of your site.

 

Regenerating New Sessions

It's common practice to associate a session with an anonymous user, one who hasn’t authenticated with your application. However, when a user does successfully authenticate with your application, it is absolutely paramount that the authenticated user doesn’t continue to use the same session ID.

There are a number of creative ways attackers can obtain an authenticated user’s session, especially when sites make it easy by transporting the session ID in the URL. In the case that an unauthenticated session had been hijacked by an attacker, when the legitimate user authenticates with the site, and the site has allowed the user to continue using the same session ID, the malicious user will also find that the session they had hijacked is now an authenticated session, allowing them to operate as the user.

You’ll notice, in the original function where we wired up our Sessions, we’re adding a “login” method to the Session’s prototype so it’s available to all instances of a Session object.  

session.Session.prototype.login = function(user){

       this.userInfo = user;

};

This is simply a convenience method that I can access to associate user information with a user session, such as in the case of an authentication route API. For example, like we saw back in Node.js and Password Storage with Bcrypt, following a successful login, we have access to the session object off the request.

authenticationRouter.route("/api/user/login")

   .post(async function (req, res) {

       try {

           

  //removed for brevity….


               req.session.login(userInfo, function(err) {

                   if (err) {

                       return res.status(500).send("There was an error logging in. Please try again later.");

                   }

});


However, it’s absolutely paramount not to continue using the same session ID after a user has successfully authenticated. The express-sessions module provides a convenient regenerate method for regenerating the session ID. Our login prototype method off of the Session object is a convenient place for regenerating the Session ID, so lets update:

session.Session.prototype.login = function (user, cb) {

      const req = this.req;

       req.session.regenerate(function(err){

           if (err){

               cb(err);

           }

       });

       req.session.userInfo = user;

       cb();

   };


Now, when we provide information to be associated with a user’s session following the user’s successful authentication with our application, we can also ensure that a new session has been generated for this user.  

Since cookies are what we use to transport our sessions, it's important to implement secure cookies. Let’s look at how we can do that next.

 

Cookies

We use sessions to maintain state between user requests, and we use cookies to transport the session ID between those requests. You probably don’t drive though shady neighborhoods without locking your doors, nor should you throw your sessions out in the wild without any protection.  

There are primarily 3 ways to protect the cookie, 2 that we will look at here, and a third we’ll examine in the next section when we look at serving our application content over HTTPS.  


HTTPOnly Flag

Since the session ID is of no use to the client, there is absolutely no reason that the front-end  application should ever have access to the session ID in the cookie. However, by default, any script running on the front-end application will have access to a cookie’s contents.

We can limit access to our session cookie’s content by issuing the HTTP Set-cookie header and specifying the HTTPOnly flag for our session cookie.  

HTTP Header

Set-cookie: mycookie=value; path=/; HttpOnly


We can easily provide this header by simply setting the httpOnly property on our cookie object to “true”:

app.use(session({

      //..previous removed for brevity


       cookie: {

           path: “/“,

           httpOnly: true,

   maxAge:  1800000

       }

}));


Now, only the agent (e.g. browser) will have access to the cookie in order to resubmit it on the next request to the same domain. This will directly help mitigate cross-site scripting threats that could have otherwise accessed the contents of our session cookie.

 

Secure Flag

With the HTTPOnly flag set, we limited application scripts from accessing our cookies at the front-end application. But what we haven’t stopped are prying eyes.  

Man-in-the-middle (MitM) attacks are common and can easily be carried out by anyone that has access to the network traffic. Imagine the WIFI at a local coffee house. If your session information is sent over the network without being encrypted, that information is available to anyone listening to the network traffic.

In addition to the HTTPOnly flag we specified, we can also set the Secure flag on our Set-Cookie HTTP Header. This will notify the agent (e.g. browser) that we don’t want to send our cookie on any HTTP requests unless it is a secure connection.

HTTP Header

Set-cookie: mycookie=value; path=/; HttpOnly secure


Again, we can easily do so by setting the secure property on our cookie object to “true”:

app.use(session({      

//..previous removed for brevity


       cookie: {

           path: “/“,

            httpOnly: true,

            secure: true,

            maxAge:  1800000

       }

}));


In addition, a common scenario that the secure flag helps prevent is the risk of mixed-content. It's quite common to easily have a page or site setup to use secure HTTP (HTTPS), but an individual resource on that page is being requested insecurely over HTTP.

Without the Secure flag, that HTTP request would have leaked our session cookie on that insecure request for that resource.

TIP: Content Security Policies can help report when a page consists of mixed content. In a future post, we’ll look at how content security policies (CSP) can help to address mixed-content issues.

Unfortunately, with the Secure flag set, unless we are serving our page or site over HTTPS, we have eliminated the ability to send our session cookie on each request, and that’s definitely not something we want. So, in the final section, let's look at the importance of using Transport Layer Security (TLS) when it comes to securely managing our user sessions.


Transport Layer Security

We mentioned the risk of man-in-the-middle attacks earlier and how anyone with the means can easily listen to all network traffic. But because the traffic is insecure and not encrypted, they can also eavesdrop on the information in that traffic.

mitm.png

I have already covered the underlying details about TLS. Since this isn’t strictly an article on TLS, I’m not going to rehash those details here. However, no topic on session management would be complete without mentioning the role and importance of TLS when it comes to securing user sessions.  

If we only ever want to send our highly sensitive session cookies over a secure connection, then we must serve those requests/pages over an HTTP connection using Transport Layer Security (HTTPS).  

TLS provides a number of benefits, such as integrity of the information being exchanged and validity of the server you’re communicating with. In addition, transport layer security provides confidentiality of the information being exchanged through the means of encryption. Serving your sites content over TLS can ensure that your sensitive session cookie is encrypted and not viewable by the prying eyes of a man-in-the-middle attack.

In a future post, we’ll look at the details of serving our Node.js application over HTTPS.


Conclusion

Sessions are still a highly prominent tool for maintaining user state in a very stateless environment. However, sessions are usually tightly tied to sensitive data such as authentication and identification information regarding the user. Therefore, it is absolutely paramount to implement the necessary mitigations to protect against risks such as session hijacking as well as related threats that can lead to sessions hijacking, such as man-in-the-middle and cross-site scripting attacks.

Max McCarty
ABOUT THE AUTHOR

Max McCarty is a Senior Technical Consultant at Summa with a passion for breathing life into big ideas. He is the founder and owner of LockMeDown.com and host of the popular Lock Me Down podcast. As a software engineer, Max’s focus is on software security, and strongly believes in empowering the everyday developer with the information to write more secure software. When he’s not building new applications or writing about web security, you’ll find Max burning calories with his kids and spending time with his wonderful family. He’s also a serious history buff.