If you ever made a web app in JS, chances are you used Express as a web framework, Passport for user authentication, and express-session to maintain users logged in. This article focuses on sessions and how we forked express-session to make it more secure.
First off, why forking express-session? Simple, we wanted to use express-session in Saasform, but we weren't ok with the security tradeoffs. Instead of building an independent session system, we decided to make a drop-in replacement for Express that you can use too.
Just to be crystal clear, this is a library released by Saasform; it's not a library that uses or depends upon Saasform.
Let's clarify with an example:
var express = require('express')
var parseurl = require('parseurl')
var session = require('express-session-jwt')
var app = express()
app.use(session({
secret: 'keyboard cat', // to upgrade existing Express sessions
keys: {
public: '-----BEGIN PUBLIC KEY-----\nMFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEDXMuNS4pyqkpZwij+UCcTPVStZHmG39D\nP1V7qaPCfc0ewXXbcEaJiarqjHOM5a6SVivCaUdJj+25tjMk4sPchQ==\n-----END PUBLIC KEY-----',
private: '-----BEGIN PRIVATE KEY-----\nMIGEAgEAMBAGByqGSM49AgEGBSuBBAAKBG0wawIBAQQgvK1dk5M81nax8lQxpbWo\nsB1oK9YAqRP7MwWc7wDne8ehRANCAAQNcy41LinKqSlnCKP5QJxM9VK1keYbf0M/\nVXupo8J9zR7BddtwRomJquqMc4zlrpJWK8JpR0mP7bm2MyTiw9yF\n-----END PRIVATE KEY-----'
},
}))
app.use(function (req, res, next) {
if (!req.session.views) {
req.session.views = {}
}
// get the url pathname
var pathname = parseurl(req).pathname
// count the views
req.session.views[pathname] = (req.session.views[pathname] || 0) + 1
next()
})
app.get('/foo', function (req, res, next) {
res.send('you viewed this page ' + req.session.views['/foo'] + ' times')
})
app.get('/bar', function (req, res, next) {
res.send('you viewed this page ' + req.session.views['/bar'] + ' times')
})
app.listen(3000);
You may recognize it from the original express-session example. The only differences to use our library are the import from express-session-jwt and the presence of public/private keys.
In the following, we'll first discuss some security limitations of Express sessions and then review how we solved them.
This is a great review of Express sessions done by SuperTokens. Instead of repeating everything, we'll just highlight the major issues:
Access to secrets. Express-session uses symmetric cryptography, i.e., whoever knows the "secret" can generate valid sessions. If you're using express-session in prod, we urge you to list all places where the "secret" can be found (bonus: list all people in your company that can easily access said secret).
Session fixation. You probably track sessions for non-logged-in users too. When a user logs in, do you change the session id? If not, that's called session fixation, and, unfortunately, the standard combo passport + express-session doesn't do it automatically. The problem arises if an attacker has a valid session id and tricks a user into using that id and sign in. Now the attacker has a valid, authenticated session.
Token ids stored at rest. Express-session stores session ids at rest. To be precise, it only stores the token id, not the signature, but we already discussed that the "secret" is likely easy to access; therefore, we have to assume that it's easy to reconstruct the full session token. This means that whoever has access to the store data (either in your company and/or in case of a data leak) can impersonate logged-in users.
Overwrite of destroyed sessions. Say 2 requests are happening in parallel. Req 1 starts and updates the session. Req 2 is a log-out that destroys the session. If Req 1 loads the session before the logout but saves it after, then it overwrites the log-out, and the session is alive again. Here's a test (all stores we checked suffer from this issue, the root cause is that express-session only has a "set" call to the store, while an explicit insert vs. update would solve).
Our solution uses JWT as session tokens. We use public-key cryptography (ES256) so that you can architect your system with a single "session manager" service that issues tokens and accesses the private key, while you can have as many verifiers as you need with no access to secrets. For example, as your product and team scale, you can split the session manager as an independent service where nobody SSHes into, while your main API/backend verifies sessions with just the public key.
By storing the user id in the JWT, we automatically prevent session fixation as a new JWT must be issued when the user id changes (e.g., from anonymous to logged-in user). This is one of the benefits of using JWT, alongside adding additional data into the token, e.g., roles. As your system grows, passing the JWT among internal services is a convenient way to have access to session data and perform local (to the service) authorization checks.
We never store (nor log) the full JWTs. Instead, we use a hash of the token as session-id in the data store. The hash value can't be used to impersonate users.
Finally, we implemented a mechanism of "soft delete" to keep track of destroyed sessions and prevent overwrites. We have an implementation for the default MemoryStore and the TypeORMStore; other stores should be easy to adapt (please reach out if you need another store!).
Let's close with a quick summary. Instead of the original express session, you can import our fork express-session-jwt into your project. You'll get JWT as session tokens, public-key cryptography for better access to secrets, protection against session fixation, no tokens stored at rest, and no override of logged-out sessions.
And we saved the best for last. If you have express-session already in production, our fork will transparently upgrade original sessions to JWT without logging your users out (receive the original session, load it from the store, convert in JWT and store the new session id in the store, return the JWT as a secure cookie to the user - and from now on, just use the JWT). This is what we mean by drop-in replacement.