Get E-Book
TLS, HTTPS & HTTP/2

Mutual TLS and Client Certificates

Ishtmeet Singh @ishtms/June 11, 2026/42 min read
#nodejs#tls#https#mtls#certificates

An HTTPS endpoint can have a valid server certificate and still accept requests from any client.

Plain HTTPS authenticates one side. The client checks the server certificate, the server proves it owns the matching private key, and the client validates the chain and hostname before any HTTP moves inside the encrypted TLS connection. All of that authentication runs from client toward server.

So the server still knows almost nothing about the client. It has the remote socket address. Headers might arrive. A cookie or token or signed request might arrive later. Those are application-level signals the server has to interpret on its own. TLS itself stopped once it authenticated the server.

Mutual TLS adds the second direction. Mutual TLS, usually shortened to mTLS, runs certificate authentication both ways. The server presents its certificate as before. The client presents one too, and the server validates that client certificate before it treats the connection as authenticated.

The client's half of this is a client certificate, an X.509 certificate the client presents during the TLS handshake. In backend systems it usually identifies a service, workload, device, build agent, or internal caller. It comes paired with a private key held on the client side, and during the handshake the client proves it holds that key. The server then validates the certificate chain against the client CA set configured for that service.

Once that succeeds, the server has a TLS-authenticated peer. The connected client owns the private key for a certificate that chains to a CA the server trusts.

mTLS authenticates the peer at the transport layer, and that is the whole of what it does. The certificate tells you which service or workload connected. What that peer is allowed to do still comes from your application rules around things like tenant access and operation-level policy.

The Extra Certificate Check

Plain HTTPS already runs one certificate check -

text
client
  validates server certificate
  sends HTTP request after TLS succeeds

mTLS adds the client certificate check inside the same TLS handshake -

text
server
  requests client certificate
  validates client certificate
  accepts HTTP only after that result

It is still one TLS connection over one TCP connection, with HTTP sitting above that secure connection. mTLS only changes the authentication state of the TLS socket, and it settles that state before Node starts handling HTTP.

This timing helps in Node, because strict mTLS can stop bad clients before your request handler runs. If a client sends no certificate, sends an expired one, or sends a certificate from the wrong client CA, the TLS setup fails. The HTTP parser never receives a request, and your route code never runs, because the connection never becomes an authorized HTTPS request.

Node can also request client certificates without rejecting the connection right away. This is optional client certificate mode. It helps during migrations, because you can log who presents a certificate and who fails validation before you start enforcing the rule. The cost is that rejection now happens in JavaScript, so every handler, middleware, and framework path has to apply the same rule.

Most production mTLS services use strict rejection during TLS setup -

text
TCP accept
TLS handshake
client certificate validation
HTTP request handler
application authorization

Two stage diagram showing the TLS gate verifying the client certificate then the application gate mapping the identity to permissions

Figure 11.1 - The two checks in order. The TLS layer verifies the client certificate is trusted, then the application maps that identity to permissions, and one does not replace the other.

The TLS decision lands first, before any HTTP exists. The application permission decision comes later, once the request carries enough information to judge it. By then the handler can see the method, the path, the tenant, the resource, the operation, and the authenticated identity.

The two decisions run on different input. TLS validation works from the client certificate chain, the trusted client CA set, the certificate validity dates, and the private-key proof from the handshake. Application authorization works from the authenticated identity plus the request details and your service policy.

Log those two outcomes under different names. A request can pass mTLS and still come back 403. A connection can fail mTLS and never produce an HTTP status code at all. If a dashboard files both under "auth failed", you lose the ability to tell a rejected handshake apart from a rejected operation.

The Handshake Path

The mTLS handshake starts like normal HTTPS. The client opens a TCP connection, sends a client hello, includes SNI when it has a hostname, and offers TLS versions and cipher suites. The server chooses the connection parameters, presents its own certificate chain, and proves ownership of the server private key.

mTLS adds a certificate request from the server.

That message is the certificate request, a handshake message in which the server asks the client to send a certificate. In TLS 1.3 it appears after the server has selected the connection parameters. In TLS 1.2 it appears in the older server handshake flight. The wire order changes with the TLS version, but the Node-level meaning stays fixed. The server is asking OpenSSL to collect and verify a client certificate before the secure connection finishes.

On the wire the messages arrive in this order -

text
client hello
server hello
server certificate chain
certificate request
client certificate chain
client private-key proof
finished messages
HTTP bytes

Sequence diagram of the mutual TLS handshake between client and server with the certificate request, client certificate, certificate verify, and the server verification step that sets the authorized flag

Figure 11.2 - The mutual TLS handshake message order. The server adds a certificate request, the client returns its certificate and a private-key proof, and the server verifies the chain and sets the authorized flag before the finished messages.

The client certificate chain begins with the client leaf certificate. It usually carries the intermediate certificates needed to link that leaf to the CA the server trusts. The private key never travels over the connection.

The private-key proof is the part people skip past. A certificate is public data, and anyone can copy a certificate file. What proves anything is that the client controls the private key paired with the public key inside that certificate, which it shows by signing handshake data with that key. So the server checks three things at once. The certificate chains to an accepted CA. The certificate is valid for the current time and usage. And the peer holds the matching private key.

OpenSSL does that certificate work beneath Node. Node leans on OpenSSL for the TLS state machine and the certificate verification. Your JavaScript configures the server, passes key and certificate material into a secure context, and later reads the result back from a tls.TLSSocket.

For any of this to work, the server needs a client CA, the certificate authority it trusts to issue client certificates. In private backend systems this is usually an internal CA, or a small set of them. Your public HTTPS server certificate might come from a public CA, but your accepted client certificates should normally come from a private issuer your organization controls.

A lot of mTLS servers go wrong right here, in production. Do not point the trusted client CA set at the public internet CA bundle. That bundle exists to validate public server names. An mTLS server wants a far smaller set, usually just the CA or intermediate that issues certificates for the clients allowed to call that service.

The trusted client CA set is just the collection of CA certificates the server uses to validate client chains. It might hold one CA certificate or several, and it should line up with the client issuance path your service accepts. If one service accepts certificates from payments-client-ca.pem and another accepts certificates from build-agent-ca.pem, give them separate TLS configurations, even when both use server certificates from the same issuer.

OpenSSL may also use the configured CA material when building the acceptable issuer list in the certificate request. Some clients use that list to decide which certificate to present. Node clients with an explicit cert and key usually have less guessing to do, but the server-side CA set still affects validation and can affect client certificate selection behavior.

After the client sends its chain, OpenSSL tries to build a path from the client leaf certificate to one of the trusted client CAs. Along that path it checks signatures, validity periods, and certificate usage constraints where they apply, then verifies the handshake proof that ties the private key to the certificate. Any failure here, with strict mTLS, becomes a TLS authentication failure before Node's HTTP request listener runs.

Server-side client validation does a different check than browser hostname verification. An HTTPS client verifies that the server certificate matches the hostname it meant to reach. A server validating a client certificate verifies the chain and the private-key proof at TLS level, and then the application picks which certificate field counts as identity. In most service systems that identity comes from a SAN value or another agreed certificate field. The remote IP address stays nothing more than network metadata.

Node stores the result on the TLS socket. req.socket.authorized tells you whether the peer certificate passed validation. req.socket.authorizationError contains the verification error when validation failed and the connection was allowed to continue. req.socket.getPeerCertificate() returns the peer certificate object exposed by Node.

The peer is whichever TLS endpoint sits on the other end of the connection. Call getPeerCertificate() on a server and you get the client certificate. Call it on a client and you get the server certificate.

Server Options

Node exposes mTLS through the same TLS options used by https.createServer().

The examples need these imports -

js
import { readFileSync } from 'node:fs';
import https from 'node:https';

The server needs its own HTTPS certificate and key, plus the CA material used to validate clients -

js
const tlsOptions = {
  key: readFileSync('server-key.pem'),
  cert: readFileSync('server-cert.pem'),
  ca: [readFileSync('client-ca.pem')],
  requestCert: true,
  rejectUnauthorized: true,
};

requestCert: true tells Node and OpenSSL to request a client certificate during the handshake. Without that option, the server runs ordinary HTTPS and the client has no TLS-level identity to present.

ca is the trusted client CA set for this server. Read it from the server's point of view. The server sends its own certificate through cert, and it uses ca to decide whether incoming client certificates are acceptable.

rejectUnauthorized: true makes client certificate validation part of the TLS gate. A missing or invalid client certificate closes the TLS connection before HTTP request handling. This is the usual setup for service-to-service mTLS.

After those options, the server code looks like normal HTTPS -

js
https.createServer(tlsOptions, (req, res) => {
  res.end('ok\n');
}).listen(8443);

With rejectUnauthorized: true, reaching this callback already tells you the TLS layer accepted the client certificate. The handler still has to run application authorization. Passing the TLS gate only proves the peer holds a trusted certificate. Whether the peer may touch this particular endpoint is something the handler still has to decide.

During a rollout, you may want to request client certificates and inspect failures without dropping the connection immediately -

js
const optionalClientCerts = {
  ...tlsOptions,
  rejectUnauthorized: false,
};

This configuration asks for a certificate and keeps the connection open even when validation fails. Your JavaScript can then inspect req.socket.authorized and req.socket.authorizationError -

js
if (!req.socket.authorized) {
  const peer = req.socket.getPeerCertificate();
  const message = Object.keys(peer).length
    ? 'client cert rejected'
    : 'client cert required';

  console.warn('mTLS failed', req.socket.authorizationError);
  res.writeHead(401);
  return res.end(message);
}

Use that pattern only in optional client certificate mode. Under strict mTLS, unauthorized peers get rejected during TLS setup and the request listener never runs. Put the OpenSSL reason string in your logs. A missing client certificate can still produce an authorization error, so checking the peer certificate helps you pick a useful response body during a migration.

Optional mode earns its place during rollout and diagnostics. You get to watch which clients present certificates, which issuers show up, and which validation errors occur, all before you turn on enforcement. The danger is how easy it is to get wrong. Every handler, framework hook, and middleware path has to make the same rejection decision, and one path that skips it treats an unauthorized TLS peer as a normal caller.

For steady production traffic, prefer rejection during the handshake. It keeps invalid peers away from routing and reduces the amount of application code that can get the decision wrong. Log TLS-level errors separately so failed handshakes are still visible -

js
const server = https.createServer(tlsOptions, handler);

server.on('tlsClientError', (err, socket) => {
  console.error(err.code, socket.remoteAddress);
});

tlsClientError fires for TLS errors before a secure connection is established. On an HTTPS server, that often means there is no IncomingMessage yet. The socket address can help correlate the failure with network logs. Authenticated identity begins only after the certificate becomes an accepted peer.

The ca setting is the one people get backwards. In an ordinary HTTPS client, ca means trust these CAs for the server I am calling. In an mTLS server, ca means trust these CAs for the clients calling me. The option name does not change. What changes is the role of the endpoint, and that decides which certificate gets validated.

A service with multiple accepted client populations can load multiple client CAs -

js
const clientCAs = [
  readFileSync('frontend-clients-ca.pem'),
  readFileSync('batch-clients-ca.pem'),
];

Loading both widens the set of callers that can clear transport authentication. The permission groups still come from application mapping. If frontend clients and batch clients perform different operations, the application has to map certificate identity to those permissions after TLS accepts the connection.

A service can also serve different secure contexts on different hostnames through SNI, as covered in the HTTPS chapter, and mTLS policy can change with the selected context. Keep that configuration readable. Debugging a rejected client certificate is hard enough without hostname selection, server certificate choice, and client CA choice scattered across files with near-identical names.

Client CA Set

Anyone who presents a certificate that chains to your client CA set clears the TLS client-authentication check. Nothing else in the server config controls who passes.

Your code only reads req.socket.authorized. OpenSSL is what set it, from the CA files you loaded. Add a CA and more clients clear the TLS layer. Drop one and a whole population of peers stops reaching application code.

For internal service traffic, a dedicated client CA usually beats reusing the CA that issues your public server certificates. A public server CA exists to validate server names for clients across the internet. A client CA exists to validate the callers of your service. Once those two stay in different files, review gets easier. When someone asks which clients can connect, you can point straight at the CA files in the server configuration.

Some organizations issue client certificates from a root CA through environment-specific intermediates. Production, staging, and development callers each chain to their own client intermediate. A Node server can then load only the intermediate or CA bundle for the environment it accepts.

Splitting them that way heads off a quiet class of mistake. A staging client certificate can be well formed, current, correctly signed, and paired with the right private key. If the production server trusts only the production client CA set, that staging certificate still fails during TLS setup. The application never has to check environment fields for peers that never became authenticated TLS connections.

Multiple client CAs can still be right during migrations or for different caller groups. A service might accept certificates from a legacy internal CA and a newer internal CA for a short rollout period. Another service might accept one CA for online workers and another CA for batch workers. In those cases, load both CA certificates and log which issuer family authenticated the peer. That log field tells you when the old path is still active.

Name CA files by role. client-ca.pem reads better than ca.pem, and payments-client-ca.pem reads better still. Large systems pile up server CAs, client CAs, test CAs, proxy CAs, and database CAs. The TLS option is often just ca, so the file names have to carry the meaning a human needs during review.

Keep the server certificate chain and the client CA set in separate variables -

js
const serverCert = readFileSync('server-cert.pem');
const clientCA = readFileSync('payments-client-ca.pem');

Two extra variables look like nothing, until an incident. A bad deploy that swaps those files can make the server present the wrong certificate, trust the wrong clients, or both at once. Clear names keep the review honest and keep a config dump readable.

Client CA changes also interact with connection reuse. Existing keep-alive TLS connections keep their authenticated socket state until they close. Updating a CA file on disk does not change the verification result on sockets that already completed the handshake. The server has to build a new secure context or restart. Clients with existing pooled connections may keep using those sockets until the pool closes them.

Tests hit the same issue. If you change tlsOptions.ca in a test and reuse an agent with an open socket, the next request may reuse the old TLS connection. Close the agent or use a fresh server when the test is meant to check a new certificate decision.

Optional client certificate mode has its own CA risk. With rejectUnauthorized: false, OpenSSL can report a validation error while Node still lets HTTP proceed. If your code logs every presented certificate, you may collect certificates from callers that your CA set rejected. Treat those logs as diagnostic data. Accepted identities should come only from the validated path.

A safe optional-mode rollout usually has three phases. First, request certificates and log the validation result. Second, reject unauthorized peers in one shared middleware or request entry point while still using rejectUnauthorized: false. Third, move rejection into the TLS handshake with rejectUnauthorized: true.

Do not live in optional mode. It runs two authentication paths inside one endpoint, TLS validation acting only as a warning while application code does the real rejecting. For a short migration that is workable. As a permanent design it gets fragile, because the two paths drift and the warning path stops matching the enforcing one.

What Node Keeps After the Handshake

https.createServer() wraps the HTTP server path in TLS state. The TCP socket is accepted by the server, the TLS handshake runs through OpenSSL, and only then do decrypted bytes reach Node's HTTP parser. The IncomingMessage you receive in the request listener sits above a tls.TLSSocket, and that socket carries the TLS authorization result.

Underneath sits the secure context, the native TLS configuration built from your JavaScript options. It holds the server certificate and key, the trusted client CA set, the protocol settings, and the verification behavior. requestCert flips the server verify mode so OpenSSL asks for a peer certificate. rejectUnauthorized decides whether a failed verification closes the connection.

OpenSSL runs the handshake state machine. Node feeds encrypted bytes into it and receives decrypted bytes back after the connection becomes secure. During the handshake, OpenSSL reads the configured CA store, receives any client certificate chain, and performs certificate verification before application bytes move upward.

This ordering is the reason strict mTLS can protect code that has no route-level checks of its own. A failed mTLS handshake stops before routing. The HTTP parser never sees a request line, headers, or body. There is no half-parsed request to clean up, because the connection failed before it ever turned into HTTP.

When verification succeeds, Node marks the socket as authorized. The data exposed to JavaScript is peer certificate information parsed for application use, separate from OpenSSL's live handshake state. getPeerCertificate() returns fields such as subject data, issuer data, SAN text, serial number, validity dates, fingerprints, and raw certificate data when requested. The returned fields can vary with certificate contents and Node/OpenSSL versions, so production code should handle missing optional fields.

The authorized boolean answers exactly one TLS question, which is whether the peer certificate passed validation under the configured trust rules. Identity mapping and per-route permission checks are still code you write yourself. Read authorized as the gate you check before you map identity.

After that check, put the mapped identity onto request-local state. A small object with fields like kind, value, issuer, and fingerprint travels through handlers more easily than the raw certificate object. It also keeps tests cleaner. Tests for the TLS extraction can cover certificate parsing on their own. Route tests can take an already-mapped identity and focus on permissions. The parsing bugs and the policy bugs then stay in different tests.

authorizationError is diagnostic state. In optional mode, it tells you why verification failed. In strict mode, failed peers usually disappear before a request socket exists, so tlsClientError and client-side TLS errors become the main evidence. Logs from both sides help because a server-side TLS alert can look like a vague socket failure in the client process.

Client certificate chains also have an expected order. Clients should send the leaf certificate first, followed by any intermediates needed to reach the trusted client CA. A leaf on its own works only when the server already holds enough intermediate material. Send the wrong intermediate or omit a required one and you usually get an unknown-issuer or unable-to-verify-chain error. The root certificate is normally unnecessary, since the server already has the trust anchor.

Node builds an HTTP request object only after the secure stream produces HTTP bytes. This changes how your metrics read. If you count requests only at the router, failed mTLS handshakes do not show up anywhere. Count TLS client errors at the server level as well, or a broken client rollout will look like missing traffic rather than rejected traffic.

Connection reuse changes how you read that identity. With HTTP/1.1 keep-alive, one authenticated TLS connection can carry multiple requests. The certificate identity belongs to the TLS connection, and every request on that connection inherits the same peer certificate state. If your application maps peer identity into request context, do it per request from the socket state. Do not store it in a mutable global value during the first request.

HTTP/2, which later chapters cover, lets multiple streams share one TLS connection, and the transport rule does not change. Client certificate authentication belongs to the TLS connection. Several HTTP/2 streams can share that one authenticated peer state. Route-level authorization still runs per request or per stream, because each operation has its own target and policy.

Reading Peer Identity

After a strict mTLS handshake succeeds, the request handler can inspect the peer certificate.

A tiny example looks like this -

js
https.createServer(tlsOptions, (req, res) => {
  const cert = req.socket.getPeerCertificate();
  res.end(`${cert.subject.CN}\n`);
});

The snippet gets you the API, but real identity mapping needs more care. subject.CN may be missing, may appear more than once, or may carry stale naming data. The Subject Alternative Name field is usually the better source for service identity, assuming your issuance rules define one.

You can inspect SAN and fingerprint fields like this -

js
const cert = req.socket.getPeerCertificate();

console.log(cert.subjectaltname);
console.log(cert.fingerprint256);

subjectaltname is a string in Node's parsed certificate object. It can contain entries such as DNS names, URI names, or IP addresses, depending on how the certificate was issued. Your application should parse only the SAN forms your client CA promises to issue. Treat every other field as diagnostic data until your issuance rules say otherwise.

A certificate thumbprint is a hash computed over the encoded certificate. Node exposes the related values as fingerprint, fingerprint256, and fingerprint512. People often say thumbprint when they mean one of those fingerprint values.

Thumbprints are useful for logs and lookups. They are compact, and they avoid dumping full certificate data into every log line. They also change when the certificate is reissued, even when the same service identity remains. Use them as evidence and correlation keys, but reach for stable identity fields from your issuance rules when you map long-lived permissions.

A peer certificate object can include nested issuer certificate data when you ask for the detailed form -

js
const cert = req.socket.getPeerCertificate(true);

console.log(cert.issuer);
console.log(cert.ca);

The true argument asks Node for the detailed certificate object, with issuer certificate references where they exist. It helps when you are diagnosing a certificate chain. Keep application identity mapping on the leaf certificate, unless your policy deliberately uses issuer data as well.

For request context, extract a small identity object near the request entry point -

js
function peerIdentity(req) {
  const cert = req.socket.getPeerCertificate();

  return {
    san: cert.subjectaltname,
    fp: cert.fingerprint256,
  };
}

The helper does the smallest useful thing. Production code should validate the exact SAN format it expects, reject missing values, and record the issuer or serial number when audits need them. The idea behind it stays small. Convert TLS certificate data into an application identity once, then pass that identity into your authorization code.

Do not pass the whole certificate object through the application. Each route then picks fields its own way, one reading CN, another reading a DNS SAN, another checking a thumbprint. When certificate issuance changes, only some of those routes break. Write one mapping function and keep raw certificate parsing out of route handlers.

Keep SAN parsing plain and strict. The subjectaltname string is formatted for display rather than as a typed identity object. It can hold comma-separated entries with prefixes such as DNS:, URI:, or IP Address:. Your issuance rules should state exactly which form your service accepts. The parser accepts that one form and drops the rest.

A small URI SAN helper might start like this -

js
function uriSan(cert) {
  const san = cert.subjectaltname || '';

  return san
    .split(', ')
    .find(value => value.startsWith('URI:'));
}

That helper is deliberately short. A production parser has to handle escaping and malformed input according to the issuance rules you control. The habit underneath does not change. Parse once near the TLS entry point, then pass a typed identity through the rest of the request path.

Issuer and serial number are useful support fields. The issuer tells you which CA path produced the client certificate. The serial number identifies the certificate within that issuer. Together with a thumbprint, they make logs easier to compare with CA inventory. The application identity field remains primary. A serial number alone usually has meaning only inside the issuing CA's database.

Raw certificate data is available too, but most request logs should avoid it. A certificate is public in the cryptographic sense, but it can expose internal hostnames, service names, environment names, and issuer layout. Log the fields you need for debugging and audit. Keep raw certificate dumps for targeted diagnostics.

The authorized field is still useful even with rejectUnauthorized: true. Checking it near the request entry point gives your code a clear invariant and helps optional-mode tests use the same path -

js
if (!req.socket.authorized) {
  throw new Error(req.socket.authorizationError);
}

With strict mTLS, that branch should never run for a real request. During tests or temporary optional mode, it catches the unsafe path before request handling continues.

When the peer certificate is missing, getPeerCertificate() returns an empty object. Code that immediately reads nested fields can throw a JavaScript error and hide the TLS configuration problem. Check authorized and expected certificate fields separately.

Good mTLS logs carry three things. The TLS result, the identity you selected, and the application decision. For an accepted request, log the certificate issuer or CA family, the identity field you mapped, and the operation outcome. For a rejected handshake, log the TLS error code and the socket address. For an optional-mode rejection, log authorizationError and any presented certificate thumbprint.

Never build authorization from the raw Host header or remote address just because mTLS succeeded. Those fields describe the HTTP request and network path. The certificate identity comes from the TLS peer certificate.

Authorization After mTLS

Once mTLS succeeds you have an authenticated peer, and that is the last thing TLS does for you. What the peer is allowed to do falls to your application.

In code, keep that handoff visible. Pull the identity off the TLS socket, then feed it together with the request operation into a permission check -

js
const identity = peerIdentity(req);

if (!canReadInvoice(identity, req.params.id)) {
  res.writeHead(403);
}

The policy model itself belongs to a later security chapter, so this snippet stays small. What it shows is where each piece sits. peerIdentity(req) reads off the TLS socket. canReadInvoice(...) works from the request and your service rules.

A passing handshake can stand for very different callers. It might be a deployed service holding a certificate from the internal CA, a device enrolled back in manufacturing, or a build agent running inside private CI. Certificate issuance is what decides which identity fields exist on that peer. Your application is what decides which actions that identity can perform.

Do not treat req.socket.authorized === true as permission for the whole service. That would allow every certificate issued by the trusted client CA set to call every operation exposed by the service. Sometimes that is acceptable for a very small private endpoint. More often, the CA set covers more callers than one route should trust.

Use narrow client CA sets wherever you can. An API that should only ever accept payment workers gets the payment client CA, or an intermediate dedicated to those workers. A reporting API gets the reporting client CA instead. The smaller the trust set, the less your application policy has to compensate for a wide-open TLS gate.

Even with narrow CA sets, keep the application check. A certificate tells you which caller connected, but the API still exposes individual operations. A service certificate cleared to hit /health may need separate permission before it can hit /admin/reindex.

SAN values are usually the right certificate field for identity. A DNS SAN can name a service endpoint, a URI SAN can carry a workload identity, and an IP SAN fits some device or infrastructure cases. Which one you use comes from your issuance rules. Node code should validate the exact format it expects and reject any certificate that passes TLS but lacks the application identity field.

Treat CN as legacy compatibility unless your client CA still issues it on purpose. Chapter 11 already covered SAN and CN for server certificate validation. Client certs follow the same practical rule. Pick one field in the issuance rules, document it, parse it once, and keep handlers away from ad hoc certificate reads.

A thumbprint works at a different level. It identifies one exact certificate instance, which helps during incident response or when you need a temporary allowlist. As a primary identity it is brittle, because reissuing the certificate changes the value. Certificate pinning gets its own chapter later. Here, treat a thumbprint as a short diagnostic label, and sometimes a migration aid.

Response codes follow the same split. In strict mTLS, a missing or invalid certificate fails at TLS before any HTTP status can exist. An authenticated certificate with no application permission should come back as an HTTP authorization failure. Optional mTLS may return 401 when JavaScript itself rejects a missing or bad client cert. During rollout, name the mode in your logs so these cases stay easy to tell apart.

Frameworks can hide this handoff. Middleware that reads req.socket.getPeerCertificate() and attaches req.clientIdentity is harmless enough. The version that hurts you falls back to an HTTP header when no certificate is present, and that quietly rewrites your trust model. Make any fallback behavior explicit in the code. Later chapters get into user auth, tokens, policy engines, and service identity systems, and the mTLS layer should hand every one of them a single, validated peer identity.

Client Configuration

The client side needs its own certificate and private key. When the server sends its certificate request during the handshake, a Node HTTPS client answers with whatever certificate material you configured.

Here is a client request with mTLS material -

js
const req = https.request({
  host: 'api.internal',
  port: 8443,
  cert: readFileSync('client-cert.pem'),
  key: readFileSync('client-key.pem'),
  ca: readFileSync('server-ca.pem'),
}, res => res.pipe(process.stdout));

The cert option is the client certificate sent to the server. It can include the client leaf certificate plus intermediate certificates in PEM form. The key option is the private key that matches the leaf certificate. The ca option in this client configuration validates the server certificate.

Once more, the option name holds still while the role flips. A server uses ca to validate clients. A client uses ca to validate the server.

The request still has to be ended -

js
req.on('error', err => {
  console.error(err.code, err.message);
});

req.end();

When the server requires a client certificate and the client sends none, the failure usually happens before an HTTP response exists. Depending on TLS version, OpenSSL details, and timing, the client may see a TLS alert, a socket hang up, or a connection reset. Route-level response handling will not run because there is no response.

A mismatched key and certificate fail locally during TLS setup. The client cannot prove ownership of the certificate's public key with the wrong private key. A passphrase-protected key needs the matching passphrase option. A client certificate signed by the wrong CA reaches the server, then fails server-side verification.

Client chain material also needs care. If client-cert.pem contains only the leaf certificate and the server lacks the intermediate, the server may fail chain validation. Put the leaf first, then intermediates. Keep the private key in key, not appended into a certificate chain file that gets copied around as public material.

For repeated calls, put the same TLS options on an https.Agent -

js
const agent = new https.Agent({
  cert: readFileSync('client-cert.pem'),
  key: readFileSync('client-key.pem'),
  ca: readFileSync('server-ca.pem'),
});

Requests using that agent share connection pooling behavior from the HTTP chapter, with TLS state attached to each underlying socket. The agent is also a clean place to keep mTLS material for one upstream service. Avoid one global agent with a certificate that accidentally gets used for unrelated hosts.

SNI and hostname verification still apply to the server certificate. The client certificate authenticates the client to the server. Server validation remains the client's job on the same connection. Keep rejectUnauthorized enabled for the server side of the connection. For private CAs, pass the server CA explicitly through ca.

Core https.request() and https.Agent take these TLS options directly. fetch() in Node goes through Undici, which has its own dispatcher configuration path. The requirement underneath is identical. The outbound client needs certificate and key material for its own identity, plus CA material to validate the server.

Client-side logs should print the target host, SNI name when it differs, whether a client cert was configured, and the TLS error code. Avoid logging private-key paths in shared logs when those paths reveal deployment layout. Never log private-key contents.

Testing the TLS Gate

Test mTLS at the TLS layer before you test the application route.

An endpoint can have perfect route code and still fail before HTTP exists. A local test should prove three things. The server presents the certificate you expect. The client presents the certificate you expect. And the server rejects the client when its certificate is missing or untrusted.

The OpenSSL command line is the fastest way to run that first pass, because it runs TLS directly and prints the verification result -

sh
openssl s_client -brief -verify_return_error \
  -connect 127.0.0.1:8443 \
  -cert client-cert.pem \
  -key client-key.pem \
  -CAfile server-ca.pem

The command connects as a client, presents the client certificate and key, and validates the server with server-ca.pem. -verify_return_error makes server-certificate verification failures produce a failing command. If the server requires mTLS and accepts the client certificate, the TLS connection completes. You can then type a raw HTTP request if the server expects HTTP/1.1, but the handshake result is already the main signal.

This command also catches bad client chain order before you start debugging route code.

Run the same command without -cert and -key. A strict mTLS server should reject the connection during TLS setup. Treat the s_client exit code as one signal. For a missing client certificate, OpenSSL can print CONNECTION ESTABLISHED, print Verification: OK, and exit 0 while Node emits tlsClientError and the request handler stays idle. Pair the command with a server-side tlsClientError assertion or a Node client assertion that observes the TLS alert.

Also test with a client certificate signed by a different CA. The server should reject that path too. Those negative tests catch broad client CA sets and accidental optional-mode deployments.

The Node client test should come next -

js
const req = https.request(options, res => {
  console.log(res.statusCode);
});

req.end();

Keep options close to your real production client options, with host, port, servername when needed, cert, key, and ca. If a test disables server verification, it only exercises the client-certificate half. If it sends no client certificate, it only exercises ordinary HTTPS. Name the tests so the next reader can tell which half each one runs, and which CA validates which side.

For server tests, assert both outcomes. One test should reach the request handler with a valid client certificate. Another should fail before the handler. If your tests count only HTTP responses, they will miss the failed-handshake case. Add a tlsClientError assertion or client-side connection error assertion for the rejection path.

Proxy deployments need their own tests. A direct request with x-forwarded-client-cert should fail or be ignored when the remote peer is untrusted. A request through the trusted proxy should carry the expected forwarded identity. A passthrough deployment should expose the client certificate on req.socket because Node terminates TLS. Testing only one path leaves the deployment handoff unverified.

Local development tends to cut corners, usually a header with a fake identity standing in for real certificate setup. For a unit test of authorization logic that is fine, as long as the test name says it bypasses TLS identity extraction. Integration tests should run the real TLS path. Otherwise the first real check of requestCert, ca, chain order, and private-key matching lands in production.

Proxy Termination and Forwarding

mTLS authenticates the TLS peer that terminates the connection.

If Node terminates TLS, Node sees the client certificate directly through req.socket. If a reverse proxy terminates TLS in front of Node, the proxy sees the client certificate and Node sees an HTTP request from the proxy. The peer certificate on Node's socket is absent unless the proxy opens another mTLS connection to Node with its own certificate.

One common deployment looks like this -

text
client
  mTLS to proxy
proxy
  HTTP to Node

Three proxy termination topologies showing where the client certificate is verified and what the peer certificate call returns to Node in each case

Figure 11.3 - Three proxy termination topologies. Each row marks where the client certificate is verified and what getPeerCertificate() returns to Node.

In that setup, req.socket.getPeerCertificate() describes the proxy-to-Node connection. The TLS connection that had the original client certificate ended at the proxy. Node receives only the metadata the proxy sends upstream.

Client certificate forwarding is when the TLS-terminating proxy copies selected client certificate information into the upstream request metadata. That metadata might be a header holding a URL-encoded PEM certificate, a subject string, a SAN value, a thumbprint, or some product-specific field.

Forwarded client certificate data is only as trustworthy as the proxy handoff. A public client can send HTTP headers with the same names. The edge proxy must remove incoming spoofed certificate headers, validate the real client certificate itself, then write trusted metadata on the upstream request. Node must accept those headers only from trusted proxy peers or trusted private networks.

Make that trust rule visible in code -

js
if (!isTrustedProxy(req.socket.remoteAddress)) {
  delete req.headers['x-forwarded-client-cert'];
}

The header name in that snippet is only an example. Real deployments use different names. Some proxies use x-forwarded-client-cert, others use ssl-client-cert, and some send only a verified identity string. The product documentation and proxy config define the format. Node should parse the exact format your proxy emits, after the request has passed the trusted-proxy check.

Forwarding the whole certificate hands the backend more raw material to work from, at the cost of parsing and normalization. Forwarding a single verified identity field cuts that parsing down, but pushes more trust onto the proxy. Either can work. What the deployment contract has to pin down is which component validates the certificate, which maps identity, and which applies authorization.

TLS passthrough keeps the original TLS connection intact until it reaches Node -

text
client
  mTLS through proxy bytes
Node
  terminates TLS and validates client cert

With passthrough, the proxy forwards encrypted bytes and leaves TLS termination to Node. Node holds the server certificate, the client CA set, and the mTLS validation result. Certificate inspection stays inside the application process, but HTTP-level proxy routing is limited, because the proxy cannot see the decrypted HTTP data.

Some deployments terminate mTLS at the edge and then use a second mTLS connection from the proxy to Node -

text
client
  mTLS to proxy
proxy
  mTLS to Node

In that setup, Node receives a peer certificate, but that peer is the proxy. Original client identity still needs forwarded metadata if the backend needs it. The backend can authenticate the proxy through mTLS and then trust selected forwarded identity fields from that proxy.

A common failure is inconsistent forwarded metadata. One proxy sends a full certificate, the next sends only a subject, and a third lets a user-supplied value pass straight through on some test route. Backends then start parsing whatever happens to be there. Keep one documented forwarded-client-certificate contract and reject anything that does not follow it.

The same warning applies to local development. A developer calling Node directly can set any header. Direct local calls should use real mTLS when testing certificate behavior, or they should enter a clearly marked test path that bypasses the proxy contract. Mixing proxy-forwarded identity headers with direct access creates bugs that appear only after deployment.

Failure Modes

mTLS failures are easier to debug when you put them at the right layer.

A missing client certificate is the simplest case. The server requested a certificate, the client sent none, and rejectUnauthorized: true caused the handshake to fail. The Node request handler stays idle. The client sees a TLS-level failure, and the server may emit tlsClientError.

An untrusted client CA means the client sent a chain, but OpenSSL could not build a path from it to the trusted client CA set. Start with the server's ca option. Check whether the client actually sent the intermediates it needed. Then confirm the presented certificate really was issued by the client CA you intended.

An expired client certificate fails on its validity period. The certificate can be well formed and signed by the correct issuer, but OpenSSL rejects it because the current time falls outside the certificate validity period. Clock skew can make this appear around issuance and expiry times.

A wrong SAN is usually an application identity failure. TLS may accept the certificate because the chain validates. Your identity mapper rejects it because the SAN value is missing, malformed, or assigned to another service. That should produce an application authentication or authorization response, depending on your optional or strict mTLS mode.

A missing intermediate breaks chain construction. The client sends the leaf certificate but omits an intermediate the server needs. The server may already have that intermediate in its trust material, which can make the bug environment-specific. Keep client certificate files complete, leaf first, then intermediates.

A wrong certificate purpose can appear when issued certificates include usage constraints. A certificate issued for server authentication may fail when used as a client certificate. The exact OpenSSL error depends on the certificate extensions and verification path. Fix the issuance rules instead of weakening server verification.

A mismatched private key fails before the client can authenticate. The certificate's public key and the configured private key do not match. The client process usually reports an OpenSSL key or PEM error before it sends a useful handshake.

The wrong client CA set is a configuration bug with security impact. If the set is too narrow, legitimate clients fail. If the set is too broad, unintended clients can authenticate at TLS level. The service still has application authorization, but the transport gate is wider than expected. Keep CA file names explicit and review them during deployment changes.

Proxy termination failures look different. The client may authenticate successfully to the proxy while Node rejects the request because forwarded identity metadata is missing. Or Node may accept a forwarded header from an untrusted peer because direct access was left open. Add logs at the proxy and backend handoff, with one request ID crossing both logs.

For strict mTLS, the server request count is the wrong primary metric. Count accepted secure connections, TLS client errors, and application requests separately. A spike in TLS client errors with flat HTTP request counts means clients are failing before the application layer. A spike in 403 with healthy TLS counts means authentication succeeded and application authorization rejected the operation.

For optional mTLS, inspect authorizationError near the request entry point -

js
console.log({
  authorized: req.socket.authorized,
  error: req.socket.authorizationError,
});

Keep that output out of route handlers. The authorization state belongs to the TLS socket, and every request on that socket shares it.

Node error codes and OpenSSL reason strings shift across versions and platforms. Use them as diagnostics rather than as policy inputs. Build policy from the certificate identity fields and the validation result. Keep the error strings for logs, dashboards, and support playbooks.

A Small mTLS Endpoint

Here is the smallest useful server setup with strict mTLS and a local identity mapper.

The handler can read the peer certificate after the handshake succeeds -

js
function handler(req, res) {
  const cert = req.socket.getPeerCertificate();
  const identity = cert.subjectaltname;

  res.end(`hello ${identity}\n`);
}

This handler runs on the assumption that the TLS layer already authenticated the client certificate. Authorization still needs its own check. That extracted field is what would feed a real policy function.

The server setup stays explicit -

js
const server = https.createServer(tlsOptions, handler);

server.on('tlsClientError', err => {
  console.error('mTLS failed', err.code);
});

tlsOptions defines the TLS gate. The handler defines request behavior, and the error listener gives you visibility into failed handshakes.

Add an application permission check and the request flow becomes easy to read -

js
function handler(req, res) {
  const identity = peerIdentity(req);

  if (!canCall(identity, req.method, req.url)) {
    res.writeHead(403);
    return res.end('forbidden\n');
  }

  res.end('done\n');
}

The TLS layer authenticated the connection before this code ran. peerIdentity() maps the certificate into an application value. canCall() decides whether the request is permitted.

The same structure works behind a proxy, but the source of identity changes. With TLS passthrough, peerIdentity() reads req.socket. With edge termination, it reads trusted forwarded metadata after the trusted-proxy check. Mixing both modes in one function is possible, but the deployment mode should be explicit in configuration. Hidden fallback rules create security bugs.

In code, mTLS looks small, because the server options themselves are small. The real work sits in the decisions around them, like which client CA set to trust, which certificate field to read as identity, how to log TLS failures, and how to keep authorization out of the handshake result. Node hands you the socket state. OpenSSL verifies the chain and the private-key proof. What the authenticated peer may do next is still your service's call.