Authentication with JWT in Node.js

Token-based authentication methods are very popular when authenticating users with Node.js servers. In a client-server relationship JSON Web Tokens are often used to verify the integrity of the user.

Session-based and token-based authentications are two ways of making sure that a user is who they say they are.

Sessions can be stored in memory or can be persisted in a database.

Token based authentication methods have a piece of string called token (can be encrypted or unencrypted) in the request header which lets the server know of the user or, at least, it ensures that the client making the request is legit.

1. JWT

One frequently used way of token-based authentication methods involves the use of JWTs.

JWT stands for JSON Web Token and it’s an open standard for securely transmitting information between client and server. JWTs are signed with a secret or a public/private key pair, and they ensure the integrity of the request.

JWTs consist of a header (before the first dot), a payload (between the two dots) and a signature part (after the second dot), and they look like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The header contains the algorithm and the token type. They payload can carry information - among others - about the client, the expiration and the time of the token was issued, while the signature confirms that the token is genuine. But, anything can be put in the payload as long as the information is not sensitive.

JWTs can be viewed in the jwt.io website, where payloads can be decoded and signatures can be verified.

JWTs make it possible for the authentication process to involve no sessions or databases, hence the server is stateless (it doesn’t know about session information).

2. Applying JWTs

I’ll rely on the Node.js and Express.js server I wrote about in the abovementioned posts, and I won’t repeat each step here, only those ones that are different from the original. The full code can be found at the bottom of the page.

2.1. Install jsonwebtoken

I’ll use the jsonwebtoken library to generate, sign and verify JWTs, so the library needs to be installed to the project folder:

npm install jsonwebtoken

Let’s require it at the top of server.js:

// ...
const jwt = require('jwt');
// ...

We’ll also need a secret which the tokens will be signed with. Ideally, they are stored in a secure place like AWS Parameter Store, and fetched from there.

In this case, the secret will be a secret string:

const secret = `secret-string` // stored in a secure place and fetched from there;

2.2. authorize middleware

A piece of middleware function can be created to verify the token and can be added to endpoints that need to be protected and only used by authenticated users.

The authorize middleware can look like:

const authorize = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  if (!authHeader) {
    return next(new CustomError('Unauthorized', 401));
  }

  const bearer = authHeader.split(' ');
  const token = bearer[1];

  try {
    const decoded = jwt.verify(token, secret);
    console.log('user authenticating:', decoded);
    req.user = decoded;
    return next();
  } catch (e) {
    return next(new CustomError('Unauthorized', 401));
  }
};

The token can be transmitted in the Authorization header. If the header is not present, the server will return a 401 error.

If it’s there, the header can look like this:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The value of the header needs to be split at the whitespace between the word Bearer and the token itself, and then the second element of the resulting array (which is the token itself) will be verified.

The verify method will throw an error if the verification is unsuccessful, and we can send the error to the error handler function.

If the token can be decoded, we can save the decoded value to the user property of the request. The token will contain the username and it will be added in the /login handler.

2.3. Signing the token

The JWT will be signed in the /login route handler after the user authentication (correct username and password) is successful:

const signed = jwt.sign({
    username: user,
    exp: Math.floor(Date.now() / 1000) + 3600,
  }, secret);
console.log('Authentication successful');

res.status(200).json({ token: signed });

The sign method accepts the secret and the payload for the token.

The payload will be an object, which has the username property and the exp expiration time in seconds (it’s one hour in this case). After one hour the token will become invalid, and the verify method in the authorize middleware will throw an error, which makes it impossible for the user to log in.

2.4. The code

The full code can look like this:

const express = require('express');
const { MongoClient } = require('mongodb');
const bodyParser = require('body-parser');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const secret = 'secret-string'; // to be stored in a secret place

const app = express();

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

app.use(bodyParser.json());

class CustomError extends Error {
  constructor(message, status) {
    super(message);
    this.status = status;
    this.message = message;
  }
}

const authorize = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  if (!authHeader) {
    return next(new CustomError('Unauthorized', 401));
  }

  const bearer = authHeader.split(' ');
  const token = bearer[1];

  try {
    const decoded = jwt.verify(token, secret);
    console.log('user authenticating:', decoded);
    req.user = decoded;
    return next();
  } catch (e) {
    return next(new CustomError('Unauthorized', 401));
  }
};

async function main() {
  let client
  try {
    client = await MongoClient.connect(MONGO_URL)
    console.log('Connected to database')
  } catch (e) {
    throw new CustomError('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 CustomError('No username or password', 404))
    }

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

    const isExistingUser = await db.collection('users').find({ username })
    if (isExistingUser) {
      return next(new CustomError('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 CustomError('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 CustomError('Missing username or password'));
    }

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

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

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

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

      const signed = jwt.sign({
        username: user,
        exp: Math.floor(Date.now() / 1000) + 3600,
      }, secret);
      console.log('Authentication successful');

      res.status(200).json({ token: signed });
    } catch (e) {
      console.log(e)
      return next(new CustomError('Error while logging in'));
    }
  });

  // private endpoint
  app.get('/private', authorize, (req, res, next) => {
    // middleware handles errors
    res.send('Private endpoint hit');
  });

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

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

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

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

2.5. Try it out

Let’s try if the token-based authentication works! (It should.)

First, start the MongoDB database:

docker-compose up -d

The database and server setup can be found in this post.

Now the server can be started:

node server.js

We can now log in James Bond:

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

The route handler sends the token back:

res.status(200).json({ token: signed });

As such, the response should look like this:

{
  "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImphbWVzLmJvbmQiLCJleHAiOjE1NzQ0NjA4ODgsImlhdCI6MTU3NDQ1NzI4OH0.mSfwEijOmDzOS5BUa_pr6ORIG6Lkr1YpgoqzsM2lYWw"
}

If this token is pasted in the token decoder, we’ll get the payload back:

{
  "username": "james.bond",
  "exp": 1574460888,
  "iat": 1574457288
}

That’s almost magic.

What even more magic is that the /private endpoint can be hit and the authorize middleware will verify the token:

curl -H 'Content-Type: application/json' \\
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImphbWVzLmJvbmQiLCJleHAiOjE1NzQ0NjA4ODgsImlhdCI6MTU3NDQ1NzI4OH0.mSfwEijOmDzOS5BUa_pr6ORIG6Lkr1YpgoqzsM2lYWw' \\
localhost:3000/private

When we curl the endpoint, we’ll need to provide the token in the Authorization header. In a real world application the token will be placed in the request header in the client side (e.g. a React or Angular).

Now let’s try to access the /private endpoint without the token in the header. The authorize middleware should prevent the user from accessing the private content:

curl -H 'Content-Type: application/json' localhost:3000/private

The response should be Unauthorized with a 401 status code.

3. Summary

Authentication with JWT is a very popular way to making sure that the user is who they claim to be.

JWT stands for JSON Web Token and it’s an open standard. The jsonwebtoken library is one that can perform the signature and the verification of the token. It’s a good idea to create middleware function to verify the token and attach the middleware to various endpoints that need to be protected.

Thanks for reading and see you next time.