Client Side SSL in node.js with fetch

Working with more advanced services sometimes requires two-way SSL to be established between the services. Let's see how we can do it using fetch.

lock image

Heads up

If you don't know what TLS/SSL with client certificate authentication is, what two-way SSL means or is used for, I'd recommend you read this as a primer: https://stackoverflow.com/a/10725958


Other resources

There's no lack of resources for how to get two-way SSL (or authorisation using SSL certificates) in node.js but most articles, when referring to the client either use request or plain https.

Here's a few examples of the resources I found to be particularly useful if that is what you're looking for:


Why fetch and not request or https?

For me it was the ability to use await like on the web and because I like the way fetch handles parameters more than https and that it is lighter as a tool compared to request.

Wiring it up

I'm going to assume you already have your certificates for the client available. If not, please use the first article recommended as a good starting point. In our case the certificates were already provided by a 3rd party so we didn't generate them ourselves. If you're working with Open Banking or other such APIs that frequently want to limit client access you may find yourself in a similar situation.

So, how does it work?

Let's look at the signature that fetch has in node.js:

// fetch(url[, options])

// Options can be:
{
	// These properties are part of the Fetch Standard
	method: 'GET',
	headers: {},        // request headers. format is the identical to that accepted by the Headers constructor (see below)
	body: null,         // request body. can be null, a string, a Buffer, a Blob, or a Node.js Readable stream
	redirect: 'follow', // set to `manual` to extract redirect headers, `error` to reject redirect
	signal: null,       // pass an instance of AbortSignal to optionally abort requests

	// The following properties are node-fetch extensions
	follow: 20,         // maximum redirect count. 0 to not follow redirect
	timeout: 0,         // req/res timeout in ms, it resets on redirect. 0 to disable (OS limit applies). Signal is recommended instead.
	compress: true,     // support gzip/deflate content encoding. false to disable
	size: 0,            // maximum response body size in bytes. 0 to disable
	agent: null         // http(s).Agent instance or function that returns an instance (see below)
}

Did you spot it? The gotcha is the agent option, which as the docs mention doesn't exist on the web.

A node agent is resposible for managing persistence and reuse of client http(s) connections. It also takes care of destroying sockets or keeping them alive in case we make another request to the same address and port (not to be confused with the Connection: Keep-Alive header).

So all we have to do to use fetch with SSL client certificate authentication is to create a new https.Agent. In our case, we had a public key file (.pem) and private key file (.key) with a passphrase so we'll be using those. Here's how that looks like:

// index.js
// using `esm` to get imports to work in node
import path from 'path';
import fs from 'fs';
import https from 'https';
import fetch from 'node-fetch';

const reqUrl = 'https://your-server.com';

const headers = {
  Accept: 'application/json',
  // add what you need like you would normally
};

async function makeRequest() {
  // you can also pass a ca or a pfx cert and much more! https.Agent uses the same options as tls.createSecureContext:
  // https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options
  const options = {
    // when using this code in production, for high throughput you should not read
    //   from the filesystem for every call, it can be quite expensive. Instead
    //   consider storing these in memory
    cert: fs.readFileSync(
      path.resolve(__dirname, './path/to/public-cert.pem'),
      `utf-8`,
    ),
    key: fs.readFileSync(
      path.resolve(__dirname, './path/to/private-key.key'),
      'utf-8',
    ),
    passphrase:
      '<your-passphrase>',

    // in test, if you're working with self-signed certificates
    rejectUnauthorized: false,
    // ^ if you intend to use this in production, please implement your own
    //  `checkServerIdentity` function to check that the certificate is actually
    //  issued by the host you're connecting to.
    //
    //  eg implementation here:
    //  https://nodejs.org/api/https.html#https_https_request_url_options_callback

    keepAlive: false, // switch to true if you're making a lot of calls from this client
  };

  // we're creating a new Agent that will now use the certs we have configured
  const sslConfiguredAgent = new https.Agent(options);

  try {
    // make the request just as you would normally ...
    const response = await fetch(reqUrl, {
      headers: headers, // ... pass everything just as you usually would
      agent: sslConfiguredAgent, // ... but add the agent we initialised
    });

    const responseBody = await response.json();

    // handle the response as you would see fit
    console.log(responseBody);
  } catch (error) {
    console.log(error);
  }
}

makeRequest();

That's all, you should be able to swap in the path for where your certificates are, the passphrase (if you have one) and the URL and you're good to go.

Last things

In order to have this really be useful in production, I would really recommend implementing your own checkServerIdentity function and preferably pin the certificate based on a fingerprint.

If you end up finding this useful (or have issues with this) please reach out, ping me on twitter and I'll help you out!

Good luck!