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:
- Must be random;
- 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 thedocument.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 andPath
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',
}))
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'],
}))
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
}
}))
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"
}))
By default, the cookies are set to
{ path: '/', httpOnly: true, secure: false, maxAge: null }
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
}
}))
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)
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
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
})
)
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' });
});
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
})
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
})
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())
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 hideX-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
})
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!')
})
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.