NodeJS

Building secure NodeJS APIs with JWTs

Overview

Authentication is a trickier subject today than it was 10 years ago. There are hundreds of potential strategies for securing APIs and ensuring that a user is, in fact, who they say they are. When building or refactoring APIs, developers have to decide what authentication strategy makes sense for their particular product use case. This can be daunting, as choosing the wrong approach can lead to difficulties down the road including data breaches, trouble building integrations, and user experience limitations that plague product development. At BoltSource, we’ve had great success leveraging the generic and flexible stateless authentication mechanism provided by JSON Web Tokens. Most of our engineers have 5+ years of battle hardened experience using JWTs full-stack in large-scale consumer and enterprise grade systems. In this post, we’re going to share what we’ve learned about building APIs with JWTs.

What is a JWT?

JSON Web Token (JWT) is an open standard (RFC 7519), popularized by the good folks at Auth0, that defines a compact and self-contained way for securely transmitting information between parties with an encoded JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret or a public/private key pair.

In this tutorial we are going to focus on using secrets, but know that public/private key pairs are an especially useful tool here if you plan to have a centralized authentication service that generates JWTs with a private key and want other services to be able to verify JWTs using the paired public key in order to avoid sharing secrets between services.

JWTs are encoded into a URI-friendly string, which can be passed between client and server via the header, body, or URI parameters of a request/response. In its compact form, JSON Web Token strings consist of three sections delimited by dots (.). Below is a description of each section, in order:

  1. A header, which is a Base64Url encoded object that typically contains the type (JWT) and the signing algorithm (HMAC, SHA256, RSA, etc)
  2. A payload, which is a Base64Url encoded object that typically contains a set of claims and additional data about the user (such as the user’s primary key).
  3. A signature, which is encoded by passing the secret, the encoded header, and the encoded payload into the signing algorithm and then encoding the output as Base64Url.

The output is three Base64-URL strings delimited by dots that can be easily passed between client and server in HTML and HTTP environments, while being more compact than alternatives such as SAML. If you want to see this process in action, check out the JWT.io Debugger where you can generate and inspect JWTs and their data

JWTs are used in the same manner as most other token-based authentication strategies. During the login flow, the client (such as a web application) sends the server a set of credentials. The server then looks up the performs checks against the credentials via business logic. If the credentials are accepted, the server generates a JSON Web Token and sends it back to the client as part of the response. The client, optionally storing the token in a cookie, attaches the token to all subsequent authenticated requests to the server, usually via an Authentication header using the Bearer schema. However, the server implementation can be constructed in such a way that it accepts the JWT being passed in other ways, such as in the request body or in the URI.

Simple-Secret Implementation

Now that we have a good high-level understanding of JWTs, let’s take a look at a basic implementation of JWT-based authentication using NodeJS. To get started, we’ll need to install a few packages:

$ npm install jsonwebtoken express body-parser await-to-js

We’ll be referring to a fake database package here as well, called “database” throughout the tutorial. Feel free to use whatever database you are most comfortable with and simply swap out the database access code.

Now that we’ve installed the necessary packages, let’s take a look at how login can be implemented using JWTs:

const express = require('express')
    , { json } = require('body-parser')
    , jwt = require('jsonwebtoken')
    , to = require('await-to-js')
    , model = require('./model'); // fake model

// This only lives on the server, never the client!
const JWT_SECRET = process.env.JWT_SECRET;

// create a server instance
const app = express();

// parse json bodies as "req.data"
app.use(json()); 

// add the login route handler
app.post('/login', async (req, res) => {
  const { email, password } = req.data;
  
  let err
    , user
    , token;
  
  // Validate the email and password, and grab the user if
  // the email and password are correct;
  [err, user] = await to(model.tryUserLogin(user, password));
  
  if (err) {
    return res.status(401).send({
      message: "Failed to login with provided credentials"
    });
  }
  
  // Create a JWT token, encoding the user's primary
  // key in the body for easy lookup when the token
  // is passed in a subsequent request
  [err, token] = await to(jwt.sign({ id: user.id }, JWT_SECRET));
  
  if (err) {
    // Handle random failures
    // while signing tokens
    return res.status(500).send({
      message: 'An unknown error has occurred'
    });
  }
  
  // Return the token in the body
  return res.status(200).send({
    token
  });
});

// listen on port 8080
app.listen(8080);

Here, we can see that it’s fairly easy to set up a simple login route and begin issuing JWT tokens. Next, let’s write code for a protected endpoint and middleware to verify the JWT before allowing access to the endpoint:

const verifyJWT = async (req, res, next) => {
  // Extract the authorization header
  const header = req.get('Authorization');
  
  // If no authorization header is present,
  // send back an error
  if (!header) {
    res.status(401).send({
      message: "Authorization required"
    });
    return false;
  }
  
  // Let's assume the token is sent using
  // the standard Bearer <token> schema.
  // In this case, we need to extract the
  // <token> portion of the string by
  // splitting it on the space between
  // it and Bearer.
  const token = header.split(' ')[1];

  // If there is not token, this a malformed
  // authentication header so we need to send
  // back an error message.
  if (!token) {
    res.status(400).send({
      message: 'Authentication header must be Bearer <token> format'
    });
    return false;
  }
  
  // Verify the JWT and
  // extract the user id
  // from it
  const [err, { id }] = await to(jwt.verify(token, JWT_SECRET));
  
  // If the JWT is invalid,
  // send back an error message
  if (err) {
    res.status(401).send({
      message: 'Invalid JWT token'
    });
    return false;
  }
  
  req.userId = id;
  
  if (next) {
    return next();
  }
  
  return true;
};

// Apply the verifyJWT middleware before calling
// the request handler
app.get('/user/me', verifyJWT, async (req, res) => {
  // Get the user by the id attached
  // to the request object by the
  // verify jwt middleware
  const myUser = await to(model.getUserById(req.userId));
  
  // Return my user :)
  return res.status(200).send(myUser);
});

As you can see, it’s incredibly easy to adopt JWTs. There is a very low commitment barrier here, as the implementation is not at all opinionated about things like sessions vs session-less or how the underlying data-model works.

This is a very simple example, and might be all that you need! Our engineers have built countless apps that only require this level of complexity at the level of generating and verifying tokens, when combined with a solid secret rotation strategy. The one weakness of this approach is blast radius if the JWT secret is somehow deciphered. This could happen if you forget to add rate limiting to your login and/or registration endpoints, where a bot could hammer these endpoints on a regular cadence and eventually decipher the signature commonalities into a token secret. There are several defenses against this, such as proper rate limiting, but these can be defeated by someone clever enough (e.g. using distributed lambda functions with a cluster of IPs). Rotating secrets regularly is another possibility here, but this forces all of your users to re-login every time it happens. In the next example, we’ll go over a more complicated example limits the blast radius to individual users while also compounding the complexity of reverse engineering the global secret.

Tenant Secret Implementation

In the previous example, we went over a very simple implementation of JWT that utilized a single JWT secret that is shared to generate and verify all JWTs. In this example, we are going to build on top of that to implement a more complicated secret management paradigm that we’ve come to call “tenant secrets”. Before we dive into the code, let’s discuss the high level architecture for this approach so that you can better understand the goal.

At a high level, the tenant secret implementation works much the same way as the simple secret implementation. The only key difference is that, rather than using a single shared secret to generate all JWTs, the tenant secret approach uses a unique secret for every single user. By having a unique JWT secret for every user, a malicious user can, at most, damage data that they already had access to in the first place. This is much more tenable than having an open data breach.

Implementing this approach is dead simple, and only requires an additional table / collection to store each secret object with a foreign key reference to the owning user. Using a caching system like Redis for lookups can also be helpful from a performance standpoint. We will go into more detail around storage and retrieval of tenant secrets on a later blog post. For now, let’s take a look at how the login mechanism could be implemented, using our fake model :

const express = require('express')
    , { json } = require('body-parser')
    , jwt = require('jsonwebtoken')
    , to = require('await-to-js')
    , model = require('./model'); // fake model

// create a server instance
const app = express();

// parse json bodies as "req.data"
app.use(json()); 

// add the login route handler
app.post('/login', async (req, res) => {
  const { email, password } = req.data;
  
  let err
    , user
    , secret
    , token;
  
  // Validate the email and password, and grab the user if
  // the email and password are correct;
  [err, user] = await to(model.tryUserLogin(user, password));
  
  if (err) {
    return res.status(401).send({
      message: 'Failed to login with provided credentials'
    });
  }
  
  // Find or create the JWT secret using
  // the user's id
  [err, secret] = await to(model.findOrCreateJWTSecret(user.id))
  
  // An error shouldn't happen, but if it
  // does we should handle it here
  if (err) {
    return res.status(500).send({
      message: 'An unknown error has occurred'
    })
  }
  
  // Create a JWT token, encoding the user's primary
  // key in the body for easy lookup when the token
  // is passed in a subsequent request.  We use
  // the secret that is specific to this user
  [err, token] = await to(jwt.sign({ id: user.id }, secret));
  
  if (err) {
    // Handle random failures
    // while signing tokens
    return res.status(500).send({
      message: 'An unknown error has occurred'
    });
  }
  
  // Return the token in the body
  return res.status(200).send({
    token
  });
});

// listen on port 8080
app.listen(8080);

Now let’s take a look at how we’d modify our existing middleware to support a route:

const model = require('./model'); // our fake model

const verifyJWT = async (req, res, next) => {
  // Extract the authorization header
  const header = req.get('Authorization');
  
  let err
    , secret
    , id;
  
  // If no authorization header is present,
  // send back an error
  if (!header) {
    res.status(401).send({
      message: "Authorization required"
    });
    return false;
  }
  
  // Let's assume the token is sent using
  // the standard Bearer <token> schema.
  // In this case, we need to extract the
  // <token> portion of the string by
  // splitting it on the space between
  // it and Bearer.
  const token = header.split(' ')[1];

  // If there is not token, this a malformed
  // authentication header so we need to send
  // back an error message.
  if (!token) {
    res.status(400).send({
      message: 'Authentication header must be Bearer <token> format'
    });
    return false;
  }
  
  // Extract the user id
  // from the JWT without verifying
  [err, { id }] = await to(jwt.decode(token, JWT_SECRET));
  
  // If the JWT is invalid,
  // send back an error message
  if (err || !id) {
    res.status(401).send({
      message: 'Invalid JWT token'
    });
    return false;
  }
  
  // pull the secret out
  // of storage using the user
  // id
  [err, secret] = await to(model.getSecretByUserId(id));

  // If we weren't able to find a secret
  // then this is an invalid JWT
  if(err) {
    res.status(401).send({
      message: 'Invalid JWT token'
    });
    return false;
  }
  
  // Verify the JWT using the secret
  [err] = await to(jwt.verify(token, secret));
  
  // If there is an error
  // then the JWT is invalid
  if (err) {
    res.status(401).send({
      message: 'Invalid JWT token'
    });
    return false;
  }
  
  // Set the user id on the
  // request object so that our
  // route handle can access it.
  req.userId = id;
  
  if (next) {
    // If we aren't composing middleware,
    // there should be a next function
    return next();
  }

  return true;
};

As you can see, it’s easy to customize the way that you issue and verify JWTs to fit any sort of security requirements.

Next

In our next blog post, I’ll be going into more detail about how to implement a working API complete with tenant-secret JWT authentication, PostgreSQL and Redis integration, and deployment on Kubernetes using Google Kubernetes Engine and Docker. Stay tuned!

Have questions about our content?

Join our developer community and ask a BoltSource Engineer today