# Mutual Transport Layer Security (mTLS)

> Understand how mutual transport layer security works (mTLS), configure your own chain of trust and see real-world use cases.

- Canonical: https://dennisokeeffe.com/blog/2025-04-08-mutual-transport-layer-security-mtls
- Published: 2025-04-08
- Updated: 2025-04-08
- Tags: typescript, auth

Mutual Transport Layer Security (mTLS) is an extension to Transport Layer Security (TLS) that, in addition to verifying the server's, **also verifies the connecting client's identity**.

It's an extra layer of security that can be applied at the **networking layer of the OSI model (layer 4)** and is used extensively within architectures such as financial systems and service meshes.

This post will work through defining what mTLS is, work through some key components such as **Certificate Authorities (CAs)**, **Certificate Signing Requests (CSRs)** and **x.509 certificates**, work through building an example application with your own **chain of trust** that includes a **root CA**, **intermediary CA**, **client** and **server** and round off with some real-world case studies.

## Mutual Transport Layer Security

To understand how mTLS extends TLS, let's recap how TLS 1.3 works at a high level.

The verification process of **TLS 1.3** introduces a more streamlined handshake compared to earlier versions.

It reduces the number of round trips and encrypts more of the handshake itself while maintaining strong security guarantees.

In **standard (one-way) TLS**, only the **server is authenticated**.

**A high-level summary of the one-way TLS 1.3 handshake**:

1. **Client** sends a `ClientHello`, which includes supported cipher suites, extensions (like [ALPN: Application-Layer Protocol Negotiation](https://www.keycdn.com/support/alpn)), and its **key share** (used for Diffie-Hellman key exchange).
2. **Server** responds with a `ServerHello`, its own **key share**, and its **certificate** (usually signed by a trusted Certificate Authority).
3. The **client verifies** the server's certificate using its **trust store**.
4. Both sides use their key shares to derive a **shared secret** (**ECDHE: Elliptic Curve Diffie-Hellman Ephemeral** is common).
5. A secure, encrypted channel is established using **symmetric encryption** based on the **shared secret**.

> The `ClientHello` and `ServerHello` messages are described in detail in the <a href="https://datatracker.ietf.org/doc/html/rfc8446#section-4.1">TLS 1.3 specification</a>.

In this process, the server proves its identity via the certificate, but the client remains anonymous.

In **mutual TLS (mTLS)**, the handshake includes an **optional client authentication step**, allowing both parties to verify each other's identities.

**A high-level summary of the mutual TLS 1.3 handshake**:

1. Client initiates a connection with a `ClientHello` and its key share.
2. Server replies with `ServerHello`, its own key share, and its certificate.
3. Client verifies the server's certificate.
4. **Server sends a `CertificateRequest`** message, asking the client to authenticate.
5. Client responds with its own **certificate** and a **CertificateVerify** message (proving it holds the private key).
6. Server verifies the client's certificate and signature.
7. Both parties complete the handshake using the agreed key derived from their shared key shares.
8. A secure, mutually authenticated channel is established.

> In mTLS, the connection is only considered secure **after both certificates have been verified** and the shared key is established.

TLS 1.3's enhancements make the handshake more efficient while preserving the flexibility to support mTLS when mutual identity verification is required.

In order to understand more on verification with a trusted root Certificate Authority, we need to better understand how certificates work in general and their relation to this process.

## Certificates, Authorities, Signing Requests

Let's start by defining some of the key terms:

| Term                                    | Description                                                                                                                                                                                                     |
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **X.509 Certificate**                   | A standard digital certificate format that binds a public key to an identity (e.g. a server or user). Used in TLS to prove identity. Includes fields like Subject, Issuer, Validity Period, and the Public Key. |
| **Public/Private Key Pair**             | A cryptographic pair: the private key is kept secret, and the public key is shared. If data is encrypted with one, it can only be decrypted with the other. Certificates include the public key.                |
| **Certificate Authority (CA)**          | A trusted entity that issues and signs certificates. Acts like a passport office verifying identities and vouching for them via digital signatures.                                                             |
| **Certificate Signing Request (CSR)**   | A file generated by an entity (e.g. a server) containing its public key and identity info, which it submits to a CA to request a signed certificate.                                                            |
| **Certificate Signing and Trust Chain** | A signed certificate can be verified by following the "chain of trust" up to a trusted root CA. Intermediate CAs are often used between end-entity certs and root CAs.                                          |
| **Root CA**                             | The top of a certificate chain. Usually self-signed and highly trusted. Kept offline for security and only used to sign intermediate CAs.                                                                       |
| **Intermediate CA**                     | A certificate authority signed by the root CA (or another intermediate) that signs end-entity certs. Keeps the root CA isolated and adds flexibility for revocation/rotation.                                   |
| **Self-Signed Certificate**             | A certificate signed with its own private key (e.g. a root CA or test cert). Not trusted unless explicitly added to a system's trust store.                                                                     |
| **CA-Signed Certificate**               | A certificate signed by a trusted CA. Automatically trusted by systems and browsers if the CA is in the trusted root store.                                                                                     |
| **Trust Store**                         | A collection of root certificates that a client or system inherently trusts. Used to verify whether a certificate's signature is valid and the issuer is trusted.                                               |
| **Key Share**                           | The key share is a public key generated for a temporary (ephemeral) key exchange.                                                                                                                               |

Most of these terms require some form of memorization. To help ease that process, here is an analogy with airport security that can help you better visualize how each of these concepts play a role in mTLS.

| Concept                                 | Analogy                                                                                                                                                                                                                                              |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **X.509 Certificate**                   | Your **passport** – it proves your identity, shows who issued it, and includes a photo (public key) that others can verify.                                                                                                                          |
| **Public/Private Key Pair**             | Like a **passport photo + biometric signature** – only you (private key) can match your photo in the passport (public key). The airport can scan your face/fingerprint (private key behavior) to verify the passport really belongs to you.          |
| **Certificate Authority (CA)**          | The **passport office** – a trusted organization that checks your identity and issues your passport. People trust passports **because** they trust the passport office.                                                                              |
| **Certificate Signing Request (CSR)**   | A **passport application** – you fill it out, include your details, and submit it to the passport office. It has your info and public key, but it's not valid yet.                                                                                   |
| **Certificate Signing and Trust Chain** | Imagine your passport was issued by a local passport office that was certified by the national government. Even if you don't know the local office, you trust the **chain of endorsements** up to the federal level. That's how a trust chain works. |
| **Root CA**                             | The **federal government's passport authority** – top of the trust chain. Everyone agrees to trust it. They don't deal with individuals but certify regional offices (intermediates).                                                                |
| **Intermediate CA**                     | A **regional passport office** – they verify people and issue passports, but they were certified by the root government authority.                                                                                                                   |
| **Self-Signed Certificate**             | A **DIY passport** – you just printed one yourself and said, "Trust me." The airport won't accept it unless you're flying private and have told them in advance to accept it.                                                                        |
| **CA-Signed Certificate**               | An **official passport** issued by a trusted passport office. Because the issuing office is on the trusted list, your passport is valid.                                                                                                             |
| **Trust Store**                         | The **airport's trusted list of passport authorities** – if your passport was issued by someone on the list (a known CA), you're in. If not, you're denied.                                                                                          |
| **Key Share**                           | Like the first half of a secret handshake – when you arrive at the airport, you and the officer each offer your half. When combined (privately), you both create a unique shared handshake for this trip.                                            |

For the sake of clarification in the analogy, the **airport represents the server** while **you are the client**. Where you need to get your create juices out a bit is understanding how the **certificates (passports)** are shown.

- In regular TLS, only the server shows **its passport** (this is where the creative thinking needs to come in, since airports obviously don't have passports). In this scenario, the client (you) checks if it is valid, but you don't need to show your ID.
- In mutual TLS, **both the server and client show their passports**. The airport checks your passport and you verify theirs. No one gets in without being trusted on both sides.

To best solidify these elements and the roles that they play in mTLS, we can build out our own local representation of these.

## Demo: Generating certificates

Based on our key terms, we know there are a few things that we would like to emulate locally:

1. The Root Certificate Authority.
2. An Intermediate Certificate Authority that acts on behalf of the Root Certificate Authority.
3. A valid Server certificate.
4. A valid Client certificate.

We can make use of the [openssl](https://github.com/openssl/openssl/wiki/Binaries) tool in order to create these certificates.


  Most OSs come with openssl included. If not, follow the instructions on the
  website linked before to install the binary.


First, we need to create the private key that represents **our Root CA's private key**, then use that key to **generate a x.509 certificate**. Our Root CA in our case will be **self-signed**.

```sh
# Root private key
openssl genrsa -out certs/rootCA.key 4096

# Root self-signed certificate
openssl req -x509 -new -nodes -key certs/rootCA.key -sha256 -days 3650 \
  -subj "/CN=MyRootCA" -out certs/rootCA.crt
```

We can the use it to create our **Intermediary Certificate Authority**. This process requires us to create a **Certificate Signing Request (CSR)** file.

The **CSR** file that create is then used alongside our **root CA** certificate and private key in order to create a our **intermediate CA certificate**.

To create the Intermediary CA private key, CSR and finally x.509 certificate, we can do the following:

```sh
# Intermediate CA config
cat > intermediate-ext.cnf <<EOF
[ext]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, digitalSignature, cRLSign, keyCertSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
EOF

# Intermediate CA private key
openssl genrsa -out certs/intermediateCA.key 4096

# Intermediate CSR
openssl req -new -key certs/intermediateCA.key -out certs/intermediateCA.csr \
  -subj "/CN=MyIntermediateCA"

# Use Root CA to sign the intermediate's CSR
openssl x509 -req -in certs/intermediateCA.csr -CA certs/rootCA.crt -CAkey certs/rootCA.key \
  -CAcreateserial -out certs/intermediateCA.crt -days 1825 -sha256 \
  -extfile intermediate-ext.cnf
```


  To better understanding the configuration, see the [x509v3 config page](https://docs.openssl.org/3.1/man5/x509v3_config/#subject-alternative-name).


We first setup a configuration file that is used during the **signing process**, then create the intermediary **private key** and **certificate signing request** before doing the signing to generate the **certificate**.

You might also be wondering at this point: **Why use an intermediary certificate authority?**

Here's the key idea:

- The **root certificate** (public key, usually self-signed) is shared and trusted.
- The **root private key** is the secret signing key and can be kept offline for safety.
- The root only needs to be used to sign **intermediates**, which can then go on to sign **leaf certs** (like for clients or servers).

So in practice:

- The **root CA** (offline) **signs the intermediate CA's cert once**.
- The **intermediate CA's cert** (now signed by the root) is **publicly distributed**.
- Clients will **trust the root certificate** (already in their trust store).


  There can be alternatives to this where the root certificate (but **not the
  key**) is distributed. In the case of the **intermediate cert** being
  distributed, the **root CA** is normally concatenated alongside it as the CA.


In our case, we obviously have the representation of our root CA's certificate stored on our local system, but in practice this would be stored away securely.

Understanding why we don't need the root certificate private key should become clearer as we sign the client and server certificates and show them in use.

As a next step, we can follow a similar process for signing and creating the server certificate:

```sh
# Server config
cat > server-ext.cnf <<EOF
[ext]
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = DNS:localhost
EOF

# Server private key
openssl genrsa -out certs/server.key 2048

# Server CSR
openssl req -new -key certs/server.key -out certs/server.csr \
  -subj "/CN=localhost"

# Sign with intermediate CA
openssl x509 -req -in certs/server.csr -CA certs/intermediateCA.crt -CAkey certs/intermediateCA.key \
  -CAcreateserial -out certs/server.crt -days 825 -sha256 \
  -extfile server-ext.cnf -extensions ext
```

Note that we use the **Intermediate Certificate Authority's certificate and private key** for signing this certificate.

Now, let's do the same for the client:

```sh
# Client config
cat > client-ext.cnf <<EOF
[ext]
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth
EOF

# Client private key
openssl genrsa -out certs/client.key 2048

# Client CSR
openssl req -new -key certs/client.key -out certs/client.csr \
  -subj "/CN=client"

# Sign with intermediate CA
openssl x509 -req -in certs/client.csr -CA certs/intermediateCA.crt -CAkey certs/intermediateCA.key \
  -CAcreateserial -out certs/client.crt -days 825 -sha256 \
  -extfile client-ext.cnf -extensions ext
```

Crucially, the server and client certs were **signed by the intermediate**, but clients (and the server) **only trust the root**.

We need to send the full chain when we present a cert. In order to do so, we need to combine our output certificate files:

```sh
# Concatenation of certificates
cat certs/server.crt certs/intermediateCA.crt > certs/server-chain.crt
cat certs/client.crt certs/intermediateCA.crt > certs/client-chain.crt
```

Doing this will create two new certificate files that are **inclusive** of our intermediary certificate. This way, when the certificate requestor receives the cert, it can verify:

1. The "leaf" certificate (client/server) was signed by intermediate.
2. The intermediate was signed by root.
3. The root is in the trust store.

The concatenated files will be used as our certificates, while the public `rootCA.crt` file will be used as our CA cert in our mTLS configuration that you will soon see, however it's worth pointing out that you could also concatenate the intermediate and root CA certificats to be used as the CA certificate and then just use the server certificate. The verification process will have the same result.

```sh
# Create a full CA chain file for verification as an alternative
cat certs/rootCA.crt certs/intermediateCA.crt > certs/ca-chain.crt
```

To see how all of this ties in together, let's see it at work with a simple Node.js application that can show when and how our certificates are used.

## Demo: mTLS application with our generated certificates

In our demonstration, we will have both a server receiving requests and a client making the requests over a mTLS.

For our tiny server, we'll run the following:

```ts
// server.ts
import https from "node:https";
import fs from "node:fs";
import type { TLSSocket } from "node:tls";

try {
  // Read the files
  const serverKey = fs.readFileSync("certs/server.key");
  const serverCert = fs.readFileSync("certs/server-chain.crt"); // Using individual cert
  const caCert = fs.readFileSync("certs/rootCA.crt"); // Using full CA chain

  function isTlsSocket(socket: unknown): socket is TLSSocket {
    return !!socket && typeof socket === "object" && "authorized" in socket;
  }

  const server = https.createServer(
    {
      key: serverKey,
      cert: serverCert,
      ca: caCert,
      requestCert: true,
      rejectUnauthorized: true,
    },
    (req, res) => {
      const tlsSocket = req.socket;

      if (!isTlsSocket(tlsSocket)) {
        console.error("Invalid socket properties");
        res.writeHead(500);
        res.end("Server error");
        return;
      }

      console.log("TLS Connection Details:");
      console.log(`- Authorized: ${tlsSocket.authorized}`);
      console.log(`- Protocol: ${tlsSocket.getProtocol()}`);
      console.log(`- Cipher: ${tlsSocket.getCipher().name}`);

      if (tlsSocket.getPeerCertificate) {
        const cert = tlsSocket.getPeerCertificate(true);
        console.log(`- Client Subject: ${cert.subject?.CN || "Unknown"}`);
        console.log(`- Client Issuer: ${cert.issuer?.CN || "Unknown"}`);
      }

      if (!tlsSocket.authorized) {
        console.error(`Auth Error: ${tlsSocket.authorizationError}`);
        res.writeHead(401);
        res.end(
          `Client certificate not authorized: ${tlsSocket.authorizationError}`
        );
        return;
      }

      res.writeHead(200);
      res.end("Hello, secure world with intermediate CA!");
    }
  );

  server.on("tlsClientError", (err) => {
    console.error("TLS Client Error:", err);
  });

  server.listen(3000, () => {
    console.log("✅ HTTPS server running at https://localhost:3000");
  });
} catch (err) {
  console.error("Failed to start server:", err);
}
```

Within the `http.createServer` options, can configure our mTLS options. As you can probably infer from the naming, we make use of the public root certificate `rootCA.crt` alongside the server `private key` and our `server-chain.crt`.


  It's important to highlight that we're using the chained certificate. This is
  important to be validated against intermediaries.


We can demonstrate our client connecting over mTLS with the following script:

```ts
// client.ts
import https from "node:https";
import fs from "node:fs";
import axios from "axios";

// Read the files with better error handling
try {
  const clientKey = fs.readFileSync("certs/client.key");
  const clientCert = fs.readFileSync("certs/client-chain.crt"); // Using individual cert, not chain
  const caCert = fs.readFileSync("certs/rootCA.crt"); // Using full CA chain

  const agent = new https.Agent({
    key: clientKey,
    cert: clientCert,
    ca: caCert,
    rejectUnauthorized: true,
  });

  console.log("Starting mTLS client request...");

  axios
    .get("https://localhost:3000", {
      httpsAgent: agent,
      timeout: 10000, // Longer timeout for debugging
    })
    .then((res) => {
      console.log("✅ Server response status:", res.status);
      console.log("✅ Server response data:", res.data);
    })
    .catch((err) => {
      console.error("❌ Request failed:");
      if (err.response) {
        console.error(`Status: ${err.response.status}`);
        console.error(`Response: ${JSON.stringify(err.response.data)}`);
      } else if (err.request) {
        console.error("No response received from server");
      } else {
        console.error(`Error: ${err.message}`);
      }

      if (err.code) {
        console.error(`Error code: ${err.code}`);
      }

      // More detailed error info for TLS issues
      if (err.cause) {
        console.error("Error cause:", err.cause);
      }
    });
} catch (err) {
  console.error("Failed to read certificate files:", err);
}
```

In our case, we're making use of the [Axios package](https://www.npmjs.com/package/axios) which accepts a `httpsAgent` argument, and we configure this agent using Node's `https` agent.

If we start our server, we can see the following:

```sh
$ npx tsx server.ts

> demo-node-mtls@1.0.0 server
> npx tsx server.ts

✅ HTTPS server running at https://localhost:3000
TLS Connection Details:
- Authorized: true
- Protocol: TLSv1.3
- Cipher: TLS_AES_256_GCM_SHA384
- Client Subject: client
- Client Issuer: MyIntermediateCA
```

Now that the server is up and running, we can validate that our connection works:

```sh
$ npx tsx client.ts

> demo-node-mtls@1.0.0 client
> npx tsx client.ts

Starting mTLS client request...
✅ Server response status: 200
✅ Server response data: Hello, secure world with intermediate CA!
```

You can find [the example code on my GitHub](https://github.com/okeeffed/demo-node-mtls).

## Delegating certificates programmatically

We used the `openssl` binaries directly to work through our process of creating private keys, certificate signing requests and final signed certificates, but it's worth highlighting that you can do this programmatically.

Golang has a standard library package [crypto/x509](https://pkg.go.dev/crypto/x509) that can generate certificates for you without external packages and is very nice.

I've also included an example of doing the exact same certificate chain we did but with Golang on the [GitHub repository](https://github.com/okeeffed/demo-node-mtls/blob/main/generate_certs.go).

What makes this nice is that you can stand up your own private certificate authorities to manage the distribution of certicates.

For Node.js, packages like [node-forge](https://www.npmjs.com/package/node-forge) are available which provides support for x509 certificate generation.

## Case Study: AWS Private CA

Taken straight from [their documentation](https://docs.aws.amazon.com/privateca/latest/userguide/PcaWelcome.html):

> AWS Private CA enables creation of private certificate authority (CA) hierarchies, including root and subordinate CAs, without the investment and maintenance costs of operating an on-premises CA.

It's a managed-service for your CA requirements if you wish not to manage that yourself. It offers APIs in order for you to interface with the service and can be desired if you don't wish to create a service yourself.

As an alternative to our approach, you could make use of AWS Private CA to create all of the certificates that we did ourselves, download them via AWS APIs and reenact the same demo that we did with our Node.js configurations.

## Case Study: Istio

[Istio](https://istio.io/) is a popular service mesh that extends capabilities for Kubernetes clusters.

One of its core features is to automative mTLS enforcement for inter-service and inter-node communication within microservices.

Within Istio service meshes running in sidecar mode, each Pod is [deployed with a sidecar](https://istio.io/latest/docs/overview/dataplane-modes/) which injects an Envoy proxy.

The Istio daemon (istiod) distributes certificates between the sidecars as required and those proxies enforce mTLS between each other for secure, verified communication between Pods.

In this approach, **you do not need to configure mTLS at the client-level**. You make a request as you expect and the data plane will manage the mTLS verfication for you thanks to the proxies.

This transparency is what makes service meshes so powerful for implementing zero-trust security models **without** requiring changes to application code.

For more on its capabilities, read the [Istio documentation on certificate management](https://istio.io/latest/docs/tasks/security/cert-management/plugin-ca-cert/).

## Certificate management and security

In our own walkthrough, we store all of the certificates locally.

It's worth taking a moment to highlight that well-planned and **secure management of the entire certificate lifecycle** is absolutely necessary.

Systems like Istio automate certificate issuance and rotation, and clients/servers are configured to trust the organization's CA.

In your own work, you'll need to ensure that you **implement proper handling** of keys and certificates.

Certificates have expirations and may require revocation, so ensure that if you are running your own certificate authority to cover these bases.

It's also worth clarifying that while mTLS adds extra security and authentication, it does not by itself guarantee non-repudiation of transmitted data​. You would still need to remedy this with key signatures on request data. I have an entire section on this in my blog post [Roll Your Own API Keys](https://www.dennisokeeffe.com/blog/2025-04-07-roll-your-own-api-keys#keys-with-request-signatures).


Non-repudiation is a term that's caught me out a few times early in my career. It means that someone cannot deny that they performed an action — like sending a message, approving a transaction, or signing a document.

In the context of client-server communications, non-repudiation provides proof of origin that can be independently verified and legally upheld.

Think of it like a signed receipt that can't be forged, and only you could've created it — because it was signed using your private key, and no one else has that key.



## Conclusion

Mutual TLS (mTLS) builds on the strong foundation of TLS by introducing client verification, enabling two-way trust in secure communication. Whether you're managing communications between services in a microservices architecture or securing client-server interactions in highly regulated industries, mTLS provides an effective layer of defense.

By understanding the roles of certificate authorities, certificate signing processes, and the chain of trust, you can confidently implement mTLS in your applications.

mTLS can introduce operational complexity, but in turn it offers strong identity assurance and encryption that can be foundational for zero-trust architectures and highly secure systems.

## Links and further reading

- [Apigee | About SSL](https://docs.apigee.com/api-platform/system-administration/about-ssl)
- [Cloudflare | What happens in a TLS handshake](https://www.cloudflare.com/en-gb/learning/ssl/what-happens-in-a-tls-handshake/)
- [TLS 1.3 specification (RFC 8446)](https://datatracker.ietf.org/doc/html/rfc8446)
- [OpenSSL binaries](https://github.com/openssl/openssl/wiki/Binaries)
- [Golang x509 Package](https://pkg.go.dev/crypto/x509)
- [Istio | Certificate Management](https://istio.io/latest/docs/tasks/security/cert-management/plugin-ca-cert/)
- [AWS Private CA Documentation](https://docs.aws.amazon.com/privateca/latest/userguide/PcaWelcome.html)
- [Axios (npm)](https://www.npmjs.com/package/axios)
- [node-forge (npm)](https://www.npmjs.com/package/node-forge)
- [GitHub repository with demo](https://github.com/okeeffed/demo-node-mtls)
- [Roll You Own API Keys](https://www.dennisokeeffe.com/blog/2025-04-07-roll-your-own-api-keys)

_Photo credit: [`jrkorpa`](https://unsplash.com/@jrkorpa)_
