Mutual Transport Layer Security (mTLS)

Published: Apr 8, 2025

Last updated: Apr 8, 2025

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), 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 TLS 1.3 specification.

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:

TermDescription
X.509 CertificateA 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 PairA 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 ChainA 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 CAThe top of a certificate chain. Usually self-signed and highly trusted. Kept offline for security and only used to sign intermediate CAs.
Intermediate CAA 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 CertificateA 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 CertificateA certificate signed by a trusted CA. Automatically trusted by systems and browsers if the CA is in the trusted root store.
Trust StoreA 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 ShareThe 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.

ConceptAnalogy
X.509 CertificateYour passport – it proves your identity, shows who issued it, and includes a photo (public key) that others can verify.
Public/Private Key PairLike 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 ChainImagine 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 CAThe 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 CAA regional passport office – they verify people and issue passports, but they were certified by the root government authority.
Self-Signed CertificateA 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 CertificateAn official passport issued by a trusted passport office. Because the issuing office is on the trusted list, your passport is valid.
Trust StoreThe 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 ShareLike 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 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.

# 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:

# 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.

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:

# 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:

# 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:

# 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.

# 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:

// 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:

// 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 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:

$ 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:

$ 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.

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 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.

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 are available which provides support for x509 certificate generation.

Case Study: AWS Private CA

Taken straight from their documentation:

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 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 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.

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.

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.

Photo credit: jrkorpa

Personal image

Dennis O'Keeffe

Byron Bay, Australia

Dennis O'Keeffe

2020-present Dennis O'Keeffe.

All Rights Reserved.