Secure Your Services with mTLS

In today's security-conscious world, it's not enough for a client to simply trust the server — sometimes, the server also needs to trust the client. That's where Mutual TLS (mTLS) comes in. Unlike regular TLS, where only the server presents a certificate, mTLS requires both parties to authenticate each other using certificates. This is especially useful in microservices, API-to-API communication, and high-trust environments like finance, healthcare, and internal developer platforms.
In this post, we'll walk through a minimal working example of setting up mTLS between a client and a server using Node.js — showing you exactly how to generate certificates, configure secure communication, and avoid common pitfalls.
Step-1 : Setting up the project and CA
Initialize an empty node.js project using npm init -y
. We will follow a project structure like this:
mtls-sample/
├── certs/
├── server/
│ └── index.js
├── client/
│ └── index.js
To enable mTLS, both the client and the server need to present valid, trusted certificates — and that trust comes from a Certificate Authority (CA). The CA acts as a trusted third party that signs certificates, essentially vouching for their authenticity. In production, this would typically be a well-known CA or your organization's internal PKI. For this project, we'll create our own self-signed CA using OpenSSL. This CA will then be used to sign both the server and client certificates, forming the foundation of trust that enables secure, two-way communication.
openssl genrsa -out certs/ca.key 4096
openssl req -x509 -new -nodes -key certs/ca.key -sha256 -days 3650 -subj "/C=US/ST=CA/O=MyOrg, Inc./CN=MyTestCA" -out certs/ca.crt
Step-2: Generating server and client certs
To make sure our server certificate works with modern clients like browsers or axios
, we need to include something called a Subject Alternative Name (SAN). Older systems used the CN
(Common Name) field, but newer clients require SAN to trust the certificate.
That’s why we created the server.cnf
file. It tells OpenSSL to add localhost
as a valid domain in the SAN section when generating the certificate. Without this, even if the CN is correct, the certificate might be rejected.
If you're working on a real app, you can add more domains to this file:
# certs/server.cnf
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
# CN is required but legacy; modern clients use Subject Alternative Name (SAN) below:
CN = localhost
[v3_req]
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
Now lets generate the server cert and key using the config we defined just now:
openssl genrsa -out certs/server.key 2048
openssl req -new -key certs/server.key -out certs/server.csr -config certs/server.cnf
openssl x509 -req -in certs/server.csr -CA certs/ca.crt -CAkey certs/ca.key -CAcreateserial -out certs/server.crt -days 365 -sha256 -extensions v3_req -extfile certs/server.cnf
Check the Server Certificate:
Run this: openssl x509 -noout -text -in certs/server.crt
In the output, find the X509v3 extensions:
section. You MUST see a Subject Alternative Name
section that looks exactly like this:
X509v3 Subject Alternative Name:
DNS:localhost
Next, we need a similar client.cnf
file which tells OpenSSL how to generate a client certificate specifically for mTLS.
# certs/client.cnf
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = my-client
[v3_req]
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth
Here's what each part does:
CN = my-client
: Sets the Common Name to identify the client (you can name this anything meaningful).keyUsage
andextendedKeyUsage
: These ensure the certificate is valid for client authentication — not for acting as a server.digitalSignature
,keyEncipherment
: Allow the client to sign and encrypt TLS messages.clientAuth
: Declares this cert is meant for authenticating a client in mTLS.
Without these usage fields, the server might reject the certificate during mTLS because it won’t recognize it as valid for client-side authentication.
Now for generating client cert and key:
openssl genrsa -out certs/client.key 2048
openssl req -new -key certs/client.key -out certs/client.csr -config certs/client.cnf
openssl x509 -req -in certs/client.csr -CA certs/ca.crt -CAkey certs/ca.key -out certs/client.crt -days 365 -sha256 -extensions v3_req -extfile certs/client.cnf
Check the Client Certificate:
Run this: openssl x509 -noout -text -in certs/client.crt
In the output, find the X509v3 extensions:
section. You MUST see an Extended Key Usage
section that explicitly says TLS Web Client Authentication
:
X509v3 Extended Key Usage:
TLS Web Client Authentication
If this section is missing, the server (rejectUnauthorized: true
) will refuse to authenticate the client.
Step-3 : Making the server and client app
// server/index.ts
import https from "https";
import fs from "fs";
import express from "express";
const app = express();
app.get("/", (req, res) => {
const cert = req.socket.getPeerCertificate();
if (cert.subject) {
console.log("Successful connection from client:", cert.subject.CN);
}
res.send("Hello from secure server");
});
const options = {
key: fs.readFileSync("./certs/server.key"),
cert: fs.readFileSync("./certs/server.crt"),
ca: fs.readFileSync("./certs/ca.crt"),
requestCert: true,
rejectUnauthorized: true,
};
const server = https.createServer(options, app).listen(3000, () => {
console.log("Secure server listening on port 3000");
});
// client/index.ts
import https from "https";
import axios from "axios";
import fs from "fs";
const agent = new https.Agent({
cert: fs.readFileSync("./certs/client.crt"),
key: fs.readFileSync("./certs/client.key"),
ca: fs.readFileSync("./certs/ca.crt"),
rejectUnauthorized: true,
});
axios
.get("https://localhost:3000", { httpsAgent: agent })
.then((res) => console.log("Response:", res.data))
.catch((err) => console.error("Error:", err.message));
Run the server and client in separate terminals and you would be able to see request passing through. Remove the certs in either, and the request will be rejected.
Mutual TLS lets you enforce strict trust between clients and servers by requiring certificates on both sides. With this setup, only specific, verified entities can communicate with your resources, making it ideal for securing internal services, APIs, or microservices.
Bonus
In this project, we used server-to-server communication in Node.js, where both ends (client and server) had direct access to certificates. But with browser-based clients, things are more complicated.
Browsers do support mTLS, but:
The certificate must be manually installed in the browser or OS.
The browser will prompt the user to select a certificate during the TLS handshake.
You can’t load or send certs programmatically via JavaScript (
fetch
,axios
, etc.) due to security restrictions.
This makes browser-based mTLS impractical for most public-facing apps.
Better Architecture for Browser-Based Clients
A more practical and secure setup is to use a reverse proxy (like NGINX or Envoy) in front of your server. Here’s how:
[Browser] → HTTPS → [NGINX/Envoy (mTLS terminator)] → Internal Server
The reverse proxy handles mTLS and verifies the client cert.
If valid, it forwards the request to your app server (often with headers like
X-SSL-Client-CN
).Your app can then apply logic based on the authenticated identity.
This approach keeps mTLS strong, but avoids the limitations of using it directly in the browser.
Subscribe to my newsletter
Read articles from Vishesh Dugar directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
