Protecting REST Endpoints with JWTs: End-to-end Guide

Published

While researching how I could protect a REST endpoint using JSON Web Tokens (JWTs), I realized there wasn’t a cohesive guide easily searchable on Google.

After creating a proof of concept that signed and verified tokens using X.509 SSL Certificates, I thought it might be helpful to create some documentation around the different pieces.

Why JSON Web Tokens?

JSON Web Tokens are used in the example below to demonstrate access to protected endpoints (presented as bearer tokens). The beauty behind JWTs is that endpoints can verify tokens on their own, without requiring checks against an Authentication Server. For more information, see my article on Shared Authentication with JSON Web Tokens.

The example below demonstrates how X.509 SSL Certificates can be used to sign and verify JWT tokens without having to use a pre-shared private key; protected endpoints can retrieve the public certificate in an open matter, and use it when verifying tokens.

Overall Architecture

In our example, certain resources should only be accessible to authenticated users. Once users authenticate, they will be given a token that contains an expiration date and the URLs they are granted access to encoded into the payload (see this primer for more information on JWT structure). Tokens will be generated and signed using the private key from a X.509 SSL Certificate pair.

The user will then present the token to a Protected REST Endpoint to demonstrate they have been granted access by the Auth Server. The endpoint will verify the provided JWT token using the corresponding X.509 SSL Certificate public key. The SSL Certficate and public key will be loaded from a publicly accessible URL, and will not need to be pre-shared ahead of time.

A Look at the NodeJS Proof of Concept

A proof of concept was created to test the architecture above using an NodeJS Express server. The project has been posted to Github, and code snippets will be sampled below to help demonstrate features of the architecture.

1. Server Configuration and Initialization

In this example, the Authorization Server and the Protected Endpoint are running on the same host (and powered by the same code base). We will need to tell Express which public and private key certificates to use for JWT signing/verification.

This can be done in the .env file in the root of the code repository:

# jwt signing certificate configuration
privateKeyPath=/local/file/system/path/privateKey.pem
publicKeyCertUrl=taylor.callsen.me

Once configured, the NodeJS Express server will read in the public and private keys once during start-up.

The private key certificate will be read from the local filesystem path specified above, and used for signing new tokens in the /routes/token.js file:

const fs = require('fs')

const secretKey = fs.readFileSync(process.env.privateKeyPath, 'utf8')
console.log('cert secret key loaded from file path: ' + process.env.privateKeyPath)

The public key certificate will be downloaded from a public URL using the get-ssl-certificate node module, and used in the /middlewares/verifyToken.js file to verify tokens:

const sslCertificate = require('get-ssl-certificate')

let publicCert
sslCertificate.get(process.env.publicKeyCertUrl).then(function (certificate) {
  publicCert = certificate.pemEncoded
  console.log('cert public key loaded from url: ' + process.env.publicKeyCertUrl)
})

Once the server has been configured, start the NodeJS Express development server by navigating to the root code directory and execute:

npm install
npm start

If configured correctly, you should see the two console.log() statements stating that the cert public and private keys have been loaded successfully.

2. User Requests Token

First the user needs to authenticate with the Authentication Server. In this example, no credentials are required, the user simply places a GET request to /token.

The /token endpoint uses the jsonwebtoken node module from Auth0 to create and sign the token with the configured certificate’s private key. The token also includes an application specific payload, which in this case specifies which URL routes the user token has access to.

const express = require('express')
const router = express.Router()

const jwt = require('jsonwebtoken')
const fs = require('fs')

// load cert private key - executed at server start-up
const secretKey = fs.readFileSync(process.env.privateKeyPath, 'utf8')
console.log('cert secret key loaded from file path: ' + process.env.privateKeyPath)

// route: get token from certificate private key
router.get('/token', function(req, res, next) {
  
  // payload defines access to specific routes (by URL path)
  const payload = { access: [
    '/token',
    '/token/verify',
    '/image',
    // '/image/metadata'
  ]}

  const token = jwt.sign(payload, secretKey, { algorithm: 'RS256', expiresIn: 60 * 10 })
  
  res.json({ success: true, access_token: token })
})

Notice the /image/metadata route is commented out from the payload access array. We will see what happens when the user tries to access an unauthorized route in section 4 below.

3. User Presents Token to Protected Endpoint

Once the user has the access token, they can then present it as a bearer token Authorization header to the Protected Endpoint.

The /image endpoint requires a valid JWT bearer token, otherwise it will return and HTTP 401 response. The example NodeJS Express server validates JWTs using a middleware function called verifyToken:

const jwt = require('jsonwebtoken')
const sslCertificate = require('get-ssl-certificate')

// load public certificate key from url - excuted at server start-up
let publicCert
sslCertificate.get(process.env.publicKeyCertUrl).then(function (certificate) {
  publicCert = certificate.pemEncoded
  console.log('cert public key loaded from url: ' + process.env.publicKeyCertUrl)
})

// token verification middleware
const verifyToken = (req, res, next) => {
  try {

    // get bearer token
    const authorizationHeader = req.headers['authorization']
    const authorziation = authorizationHeader.split(' ')
    const bearerToken = authorziation[1]

    // confirm requested route is granted in access array of payload
    jwt.verify(bearerToken, publicCert, { algorithm: 'RS256' }, (err, payload) => {
      if (err) throw err
      else {
        if (payload.access.includes(req.originalUrl)) next()
        else throw { message: 'requested url not granted in token' }
      }
    })

  } catch (e) {
    res.status(401).json({ success: false, status: 'failed to verify token', error: e })
  }
}

The token is verified on line 21 using Auth0’s jsonwebtoken node module, along with the configured public key certificate. Once the token is verified and decoded, the payload is examined to ensure the requested route is included in the access array (line 24).

If all conditions are met, and the token is valid, the Express middleware calls the next() function to proceed with processing the request.

Using the Verify Token Middleware

The verifyToken() function shown above is then bound as middleware for all protected endpoints in the NodeJS Express app.

router.get('/metadata', verifyToken, function(req, res, next) {
  res.setHeader('Content-Type', 'application/json')
  res.sendFile(path.join(__dirname, '..', 'sample_data' , 'metadata.json'))
})

In the snippet above, the verifyToken() function is passed as an argument into the router.get() call. As a result, the verifyToken() middleware function will be executed each time this route is requested. More examples of binding middleware are available in the Express documentation here.

4. User tries to Access Unauthorized Endpoint

In the event that the user tries to access an authorized endpoint (like the /image/metadata route mentioned in section 2), the NodeJS Express server will return a 401 HTTP response to the client.

{
    "success": false,
    "status": "failed to verify token",
    "error": {
        "message": "requested url not granted in token"
    }
}

In this example project, a 401 HTTP response can be returned for several reasons including:

  • The client did not include a Bearer token in the Authorization header
  • The supplied token is invalid, or expired (tokens are active for 10 minutes in this project)
  • The token does not include the requested URL in the JWT token payload access array – this customization demonstrates how token payloads can be used to grant access to specific URLs

Further Reading

While this proof of concept was fun to create, there are a few next steps I’d take before implementing this functionality into any platform:

  • Adjust the service to read X.509 SSL Certificates from a certificate manager, possibly AWS Certificate Manager
  • Incorporate x5u or x5c token headers so that downstream endpoints know which certificate or key to verify tokens against
  • Review the security concerns related to JWTs to hopefully prevent issues

Subscribe by Email

Enter your email address below to be notified about updates and new posts.


Comments

Loading comments..

No responses yet

Leave a Reply

Your email address will not be published. Required fields are marked *