DEV Community

Cover image for Best Practices for Secure Session Management in Node
Karan Gandhi for Jscrambler

Posted on • Originally published at blog.jscrambler.com

Best Practices for Secure Session Management in Node

In a web application, data is transferred from a browser to a server over HTTP. In modern applications, we use the HTTPS protocol, which is HTTP over TLS/SSL (secure connection), to transfer data securely.

Looking at common use cases, we often encounter situations where we need to retain user state and information. However, HTTP is a stateless protocol. Sessions are used to store user information between HTTP requests.

We can use sessions to store users' settings like when not authenticated. Post authentication sessions are used to identify authenticated users. Sessions fulfill an important role between user authentication and authorization.

Exploring Sessions

Traditionally, sessions are identifiers sent from the server and stored on the client-side. On the next request, the client sends the session token to the server. Using the identifier, the server can associate a request with a user.

Session identifiers can be stored in cookies, localStorage, and sessionStorage. Session identifiers can be sent back to the server via cookies, URL params, hidden form fields or a custom header. Additionally, a server can accept session identifiers by multiple means. This is usually the case when a back-end is used for websites and mobile applications.

Session Identifiers

A session identifier is a token stored on the client-side. Data associated with a session identifier lies on the server.

Generally speaking, a session identifier:

  1. Must be random;
  2. Should be stored in a cookie.

The recommended session ID must have a length of 128 bits or 16 bytes. A good pseudorandom number generator (PNRG) is recommended to generate entropy, usually 50% of ID length.

Cookies are ideal because they are sent with every request and can be secured easily. LocalStorage doesn't have an expiry attribute so it persists. On the other hand, SessionStorage doesn't persist across multiple tabs/windows and is cleared when a tab is closed. Extra client code is required to be written to handle LocalStorage / SessionStorage. Additionally, both are an API so, theoretically, they are vulnerable to XSS.

Usually, the communication between client and server should be over HTTPS. Session identifiers should not be shared among the protocols. Sessions should be refreshed if the request is redirected. Also, if the redirect is to HTTPS, the cookie should set after the redirect. In case multiple cookies are set, the back-end should verify all cookies.

Securing Cookie Attributes

Cookies can be secured using the following attributes.

  • The Secure attribute instructs the browser to set cookies over HTTPS only. This attribute prevents MITM attacks since the transfer is over TLS.
  • The HttpOnly attribute blocks the ability to use the document.cookie object. This prevents XSS attacks from stealing the session identifier.
  • The SameSite attribute blocks the ability to send a cookie in a cross-origin request. This provides limited protection against CSRF attacks.
  • Setting Domain & Path attributes can limit the exposure of a cookie. By default, Domain should not be set and Path should be restricted.
  • Expire & Max-Age allow us to set the persistence of a cookie.

Typically, a session library should be able to generate a unique session, refresh an existing session and revoke sessions. We will be exploring the express-session library ahead.

Enforcing Best Practices Using express-session

In Node.js apps using Express, express-session is the de facto library for managing sessions. This library offers:

  • Cookie-based Session Management.
  • Multiple modules for managing session stores.
  • An API to generate, regenerate, destroy and update sessions.
  • Settings to secure cookies (Secure / HttpOnly / Expire /SameSite / Max Age / Expires /Domain / Path)

We can generate a session using the following command:

app.use(session({
  secret: 'veryimportantsecret',  
}))

Enter fullscreen mode Exit fullscreen mode

The secret is used to sign the cookie using the cookie-signature library. Cookies are signed using Hmac-sha256 and converted to a base64 string. We can have multiple secrets as an array. The first secret will be used to sign the cookie. The rest will be used in verification.

app.use(session({
  secret: ['veryimportantsecret','notsoimportantsecret','highlyprobablysecret'],
}))

Enter fullscreen mode Exit fullscreen mode

To use a custom session ID generator, we can use the genid param. By default, uid-safe is used to generate session IDs with a byte length of 24. It's recommended to stick to default implementation unless there is a specific requirement to harden uuid.

app.use(session({
    secret: 'veryimportantsecret', 
    genid: function(req) {
      return genuuid() // use UUIDs for session IDs
     }
}))
Enter fullscreen mode Exit fullscreen mode

The default name of the cookie is connect.sid. We can change the name using the name param. It's advisable to change the name to avoid fingerprinting.

app.use(session({
  secret: ['veryimportantsecret','notsoimportantsecret','highlyprobablysecret'], 
  name: "secretname" 
}))
Enter fullscreen mode Exit fullscreen mode

By default, the cookies are set to

{ path: '/', httpOnly: true, secure: false, maxAge: null }
Enter fullscreen mode Exit fullscreen mode

To harden our session cookies, we can assign the following options:

app.use(session({
  secret: ['veryimportantsecret','notsoimportantsecret','highlyprobablysecret'],  
   name: "secretname",
  cookie: {
      httpOnly: true,
      secure: true,
      sameSite: true,
      maxAge: 600000 // Time is in miliseconds
  }
}))
Enter fullscreen mode Exit fullscreen mode

The caveats here are:

  • sameSite: true blocks CORS requests on cookies. This will affect the workflow on API calls and mobile applications.
  • secure requires HTTPS connections. Also, if the Node app is behind a proxy (like Nginx), we will have to set proxy to true, as shown below.
app.set('trust proxy', 1)
Enter fullscreen mode Exit fullscreen mode

By default, the sessions are stored in MemoryStore. This is not recommended for production use. Instead, it's advisable to use alternative session stores for production. We have multiple options to store the data, like:

  • Databases like MySQL, MongoDB.
  • Memory stores like Redis.
  • ORM libraries like sequelize.

We will be using Redis as an example here.

npm install redis connect-redis 
Enter fullscreen mode Exit fullscreen mode
const redis = require('redis');
const session = require('express-session');
let RedisStore = require('connect-redis')(session);
let redisClient = redis.createClient();

app.use(
  session({
    secret: ['veryimportantsecret','notsoimportantsecret','highlyprobablysecret'], 
     name: "secretname", 
     cookie: {
      httpOnly: true,
      secure: true,
      sameSite: true,
      maxAge: 600000 // Time is in miliseconds
  },
    store: new RedisStore({ client: redisClient ,ttl: 86400}),   
    resave: false
  })
)
Enter fullscreen mode Exit fullscreen mode

The ttl (time to live) param is used to create an expiration date. If the Expire attribute is set on the cookie, it will override the ttl. By default, ttl is one day.

We have also set resave to false. This param forces the session to be saved to the session store. This param should be set after checking the store docs.

The session object is associated with all routes and can be accessed on all requests.

router.get('/', function(req, res, next) {
  req.session.value = "somevalue";  
  res.render('index', { title: 'Express' });
});
Enter fullscreen mode Exit fullscreen mode

Sessions should be regenerated after logins and privilege escalations. This prevents session fixation attacks. To regenerate a session, we will use:

req.session.regenerate(function(err) {
  // will have a new session here
})
Enter fullscreen mode Exit fullscreen mode

Sessions should be expired when the user logs out or times out. To destroy a session, we can use:

req.session.destroy(function(err) {
  // cannot access session here
})
Enter fullscreen mode Exit fullscreen mode


Side Note: While this article focuses on back-end security, you should protect your front-end as well. See these tutorials on protecting React, Angular, Vue, React Native, Ionic, and NativeScript.

Extra Security with Helmet.js (Cache-Control)

Web Caching allows us to serve requests faster. Some sensitive data might be cached on the client computer. Even if we timeout the session, there might be a possibility that the data can be retrieved from the web cache. To prevent this, we need to disable cache.

From the POV of this article, we are interested in setting the Cache-Control header to disable client-side caching.

Helmet.js is an Express library that can be used to secure our Express apps.
The noCache method will set Cache-Control, Surrogate-Control, Pragma, and Expires HTTP headers for us.

const helmet = require('helmet')
app.use(helmet.noCache())
Enter fullscreen mode Exit fullscreen mode

However, in general, it's wise to use the other options too. Helmet.js provides:

  • dnsPrefetchControl to control browser DNS prefetching.
  • frameguard to prevent clickjacking.
  • hidePoweredBy to hide X-Powered-By header.
  • hsts for HTTP Strict transport Security
  • noSniff to keep clients from sniffing MIME types
  • xssFilter to add some XSS protection.

Alternatively, if the site has the requirement to be cached, at the very least, the Cache-Control header must be set to Cache-Control: no-cache="Set-Cookie, Set-Cookie2"

router.get('/', function(req, res, next) {
res.set('Cache-Control', "no-cache='Set-Cookie, Set-Cookie2'");
// Route Logic
})
Enter fullscreen mode Exit fullscreen mode

Logging Sessions

Whenever a new session is created, regenerated, or destroyed, it should be logged. Namely, activities like user-role escalation or financial transactions should be logged.

A typical log should contain the timestamp, client IP, resource requested, user ID, and session ID.

This will be helpful to detect session anomalies in case of an attack. We can use winston, morgan or pino to log these requests. By default, Express comes with morgan preinstalled. The default combined setting provides us standard Apache combined log output.

We can modify morgan to include session identifiers using custom morgan tokens. Depending on the use-case we add additional data to output. Similar processes can be implemented in other logging libraries.

var express = require('express')
var morgan = require('morgan')

var app = express()

morgan.token('sessionid', function(req, res, param) {
    return req.sessionID;
});
morgan.token('user', function(req, res, param) {
    return req.session.user;
});

app.use(morgan(':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent" :user :sessionid'))

app.get('/', function (req, res) {
  res.send('hello, world!')
})
Enter fullscreen mode Exit fullscreen mode

Depending on the use case, logging scenarios should be built and implemented.

Additional Client-Side Defenses

There are some other client-side measures we can take to expire sessions.

Session Timeouts on Browser Events

We can use JavaScript to detect if the window.close event is fired and subsequently force a session logout.

Timeout Warnings

A user can be notified of session timeouts on the client-side. This will notify the user that his session is going to expire soon. This is helpful when a long business process is involved. Users can save their work before timeout OR continue working.

Initial Login Timeout

A client-side timeout can be set between the page that was loaded and the user that was authenticated. This is to prevent session fixation attacks, especially when the user is using a public/shared computer.

Alternatives

Currently, JWT is a viable alternative to the session. JWT is a stateless Auth mechanism. A Bearer token is sent in the header of every authenticated request. The payload of the JWT token contains the necessary details used for authorization. This is useful when we want to expose some part of our data as an API resource. However, unlike sessions, JWT is stateless and hence the logout code has to be implemented on the client-side. You can set an expiry timestamp in JWT payload but cannot force a logout.

Final Thoughts

As we explored in this tutorial, managing sessions securely in Node/Express apps is a key security requirement.

We have highlighted some techniques to prevent some very serious attacks like CRSF, XSS, and others that might expose sensitive user information.

At a time when web-based attacks are growing fast, these threats must be addressed while developing the app to minimize the application’s attack surface.


For some further reading on security in JavaScript apps, check this data sheet.

Top comments (2)

Collapse
 
rishpoddar profile image
Rishabh Poddar

Great article! Thanks. However, express-session focuses on a rather simple session flow. One can implement much better security by using short-lived access tokens and one-time use, long-lived refresh tokens as it's explained here: supertokens.io/blog/all-you-need-t....
In a nutshell, this method allows you to change the secret key instantly (as opposed to gradually fading out a key as shown in the article) and also enables detection of token theft (which express-session also doesn't provide).

Collapse
 
karandpr profile image
Karan Gandhi • Edited

Thank you for your comments!

As far as better security is concerned, it boils down to use cases and constraints(Time & Technical).

This article was written explicitly with express-session in my mind. It's a flexible library with which I can roll out my solutions depending on the use case.