Storing session data in MongoDB with Node.js

Storing session data in a database is a recommended approach when doing authentication with session. There are several options for persisting sessions. In this post I'll give a brief overview about how to store session data in MongoDB.

In the last post I added in-memory session to the authentication process to a server built in Node.js and Express.js. Although it might be a good idea to read the referred post first, I’ll provide the code below, so it’s not a pre-requisite for this post.

The password was encrypted earlier using bcrypt and it was stored in the database.

1. Disadvantages of in-memory session store

But storing session data in memory has some disadvantages.

The maintainers of express-session (which I use to create session) don’t recommend using in-memory session store in production, because it can lead to memory leaks. This is definitely something we don’t want.

Storing session data in memory has another side effect as well. If the server needs to be restarted for some reason, the session data will be lost.

This behaviour can easily be simulated by logging in with the credentials and simply restarting the server (more on that below).

2. Persisting session data

A better solution is to store session data in a database.

In this case it won’t matter if the server restarts because the session data store is decoupled from the server, and the user can continue to access protected content with the session id placed in the cookie of their browser (or, in this case, in the cookie.txt file).

2.1. Store the session in DynamoDB

Amazon DynamoDB is a great solution to store session data. I wrote a post on this topic earlier this year, so I’m not going to go into more detail here. Please read that post if this paragraph has sparked your interest.

2.2. Store the session in MongoDB

In this post though I’ll show how to store the session data in MongoDB.

MongoDB is a handy solution here because we use it as our database anyway, and the credentials of James Bond are already stored there (with encryption, of course, how else).

To do this, we’ll need to install a library called connect-mongo:

npm install connect-mongo

This package works well with express-session, and a slight modification of the code will result in the session data to be persisted in the database.

Let’s require the package at the top of the file:

// ... other packages required
const MongoStore = require('connect-mongo')(session)

// ... other code

const MONGO_URL = 'mongodb://localhost:27017'

const sessionOptions = {
  secret: 'my-super-secret',
  resave: false,
  saveUninitialized: false,
  cookie: {
    maxAge: 3600000,
  },
  store: new MongoStore({ url: MONGO_URL }),
}

if (app.get('env') === 'production') {
  sessionOptions.cookie.secure = true
}
app.use(session(sessionOptions))

// middleware and route handlers here

All we have to do is to add it to the store property of the sessionOptions. That’s it! connect-mongo will automatically manage the session data and it will remove it from MongoDB when the session gets destroyed upon logout.

The full code now looks like this:

const express = require('express')
const { MongoClient } = require('mongodb')
const bodyParser = require('body-parser')
const session = require('express-session')
const bcrypt = require('bcryptjs')
const MongoStore = require('connect-mongo')(session)

const app = express()

const PORT = 3000
const MONGO_URL = 'mongodb://localhost:27017'

app.use(bodyParser.json())

const sessionOptions = {
  secret: 'my-super-secret',
  resave: false,
  saveUninitialized: false,
  cookie: {
    maxAge: 3600000,
  },
  store: new MongoStore({ url: MONGO_URL }),
}
if (app.get('env') === 'production') {
  sessionOptions.cookie.secure = true
}
app.use(session(sessionOptions))

const authorize = (req, res, next) => {
  if (!req.session.user) {
    return next(new Error('Please log in'))
  }
  next()
}

async function main() {
  let client
  try {
    client = await MongoClient.connect(MONGO_URL)
    console.log('Connected to database')
  } catch (e) {
    throw new Error('Error while connecting to database')
  }

  const db = client.db('authentication-with-node')

  // register endpoint
  app.post('/register', async (req, res, next) => {
    const username = req.body.username
    const password = req.body.password

    if (!username || !password) {
      return next(new Error('No username or password'))
    }

    if (password.length < 10) {
      return next(new Error('Password must consist of at least 10 characters'))
    }

    const isExistingUser = await db.collection('users').find({ username })
    if (isExistingUser) {
      return next(new Error('User already registered'))
    }

    try {
      const hashedPassword = await bcrypt.hash(password, 10)
      await db.collection('users').insertOne({
        username,
        password: hashedPassword,
      })
      console.log('User data have been saved')
      res.redirect('/login')
    } catch (e) {
      return next(new Error('Unknown error'))
    }
  })

  // public endpoint
  app.get('/', (req, res) => {
    res.send('Public endpoint')
  })

  // login endpoint
  app.post('/login', async (req, res, next) => {
    const { username, password } = req.body

    if (!(username && password)) {
      return next(new Error('Missing username or password'))
    }

    const { username: user, password: hashedPassword } = await db
      .collection('users')
      .findOne({ username })

    if (username !== user) {
      return next(new Error('Incorrect username/password'))
    }

    const isMatchingPassword = await bcrypt.compare(password, hashedPassword)

    if (!isMatchingPassword) {
      return next(new Error("Incorrect username/password"))
    }

    console.log('Authentication successful')
    req.session.user = {
      username: user,
    }
    res.redirect('/private')
  })

  // private endpoint
  app.get('/private', authorize, (req, res, next) => {
    console.log(req.session.id)
    console.log('Store added for session')
    res.send('Private endpoint hit')
  })

  app.get('/logout', authorize, (req, res, next) => {
    req.session.destroy()
    console.log('Logging out')
    res.redirect('/')
  })

  // error handling
  app.use((err, req, res, next) => {
    res.status(err.httpStatusCode || 400).json({ message: err.message })
  })

  app.listen(PORT, () => {
    console.log(`Server is listening on ${ PORT }`)
  })
}

main().catch((e) => {
  console.log(e)
})

3. Try it out

Let’s give it a try!

If the database hasn’t been started yet, we can do it by running docker-compose up. Let’s also start the server with node server.js.

Mr Bond can now log in with his very secret credentials:

curl -X POST -H 'Content-Type: application/json' --cookie-jar cookie.txt --cookie cookie.txt \\
-d '{"username": "james.bond", "password": "mysupersecretpassword"}' localhost:3000/login

The response should be Redirecting to /private.

Now we can try to access the private content, and we should be able to do it with success:

curl -H 'Content-Type: application/json' --cookie-jar cookie.txt --cookie cookie.txt \\
localhost:3000/private

Now we’ll restart the server. First, let’s stop it with Ctrl + C, add a line to the handler of the /private endpoint:

console.log('Store added for session')

and then start the server again with node server.js.

If we try to access the private content, we should be still successful:

curl -H 'Content-Type: application/json' --cookie-jar cookie.txt --cookie cookie.txt \\
localhost:3000/private

This is because the session data is stored in MongoDB independently from the server.

James Bond can now log out:

 curl -H 'Content-Type: application/json' --cookie-jar cookie.txt --cookie cookie.txt \\
 localhost:3000/logout

The session should be destroyed, and the second last curl command (with the /private endpoint) should be unsuccessful.

4. Summary

Storing session data in the memory is not recommended.

A better approach is to have it persisted in a database like DynamoDB or MongoDB because a server restart won’t destroy the session, and the user can continue where they left off after the server has gone back to operation.

Thanks for reading and see you next time.