Version
v26.8.0 and up. v26.7.0 is unaffected. Reproduces on current main.
Platform
Linux 6.18.35.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC x86_64 GNU/Linux
Also reproduced on GitHub Actions ubuntu-latest. OpenSSL is 3.5.7.
Subsystem
tls
What steps will reproduce the bug?
On the server side of a mutual-TLS connection, socket.getPeerCertificate(true) no longer returns the client certificate's issuer chain. The leaf is returned fully populated, but issuerCertificate is undefined, even though the client presented [leaf, intermediate] and the socket is authorized.
Here is a repo script that creates a root -> intermediate -> client chain with openssl, then runs a node:https mTLS handshake and reads the peer certificate on the server:
import https from 'node:https';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// Generate root CA -> intermediate CA -> client leaf and server cert
const dir = mkdtempSync(join(tmpdir(), 'mtls-'));
const f = (n) => join(dir, n);
const openssl = (...a) => execFileSync('openssl', a, { stdio: 'ignore' });
const ec = ['-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes'];
openssl('req', '-x509', ...ec, '-keyout', f('root.key'), '-out', f('root.crt'),
'-days', '2', '-subj', '/CN=Repro Root CA', '-addext', 'basicConstraints=critical,CA:TRUE');
writeFileSync(f('int.ext'), 'basicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign\n');
openssl('req', ...ec, '-keyout', f('int.key'), '-out', f('int.csr'), '-subj', '/CN=Repro Intermediate CA');
openssl('x509', '-req', '-in', f('int.csr'), '-CA', f('root.crt'), '-CAkey', f('root.key'),
'-CAcreateserial', '-days', '2', '-extfile', f('int.ext'), '-out', f('int.crt'));
writeFileSync(f('leaf.ext'), 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=clientAuth\n');
openssl('req', ...ec, '-keyout', f('leaf.key'), '-out', f('leaf.csr'), '-subj', '/CN=Repro Client');
openssl('x509', '-req', '-in', f('leaf.csr'), '-CA', f('int.crt'), '-CAkey', f('int.key'),
'-CAcreateserial', '-days', '2', '-extfile', f('leaf.ext'), '-out', f('leaf.crt'));
openssl('req', '-x509', ...ec, '-keyout', f('srv.key'), '-out', f('srv.crt'),
'-days', '2', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost');
const root = readFileSync(f('root.crt'));
const clientChain = Buffer.concat([readFileSync(f('leaf.crt')), readFileSync(f('int.crt'))]);
const server = https.createServer(
{ key: readFileSync(f('srv.key')), cert: readFileSync(f('srv.crt')), ca: [root],
requestCert: true, rejectUnauthorized: false },
(req, res) => {
const cert = req.socket.getPeerCertificate(true);
console.log('node', process.version, '| authorized:', req.socket.authorized,
'| issuerCertificate present:', !!cert.issuerCertificate);
res.writeHead(200); res.end('x');
});
server.listen(0, 'localhost', () => {
const req = https.request(
{ hostname: 'localhost', port: server.address().port,
key: readFileSync(f('leaf.key')), cert: clientChain, ca: [root], rejectUnauthorized: false },
(res) => { res.resume(); res.on('end', () => { server.close(); rmSync(dir, { recursive: true, force: true }); }); });
req.end();
});
Compare output on an affected and an unaffected version:
$ node-v26.7.0 repro.mjs
node v26.7.0 | authorized: true | issuerCertificate present: true
$ node-v26.8.1 repro.mjs
node v26.8.1 | authorized: true | issuerCertificate present: false
How often does it reproduce? Is there a required condition?
Every time on Node >= 26.8.0. Requires requestCert: true, a client certificate signed through at least one intermediate, and reading the chain with getPeerCertificate(true) or getPeerCertificate with includeChain.
What is the expected behavior? Why is that the expected behavior?
getPeerCertificate(true) on the server should return the leaf with an issuerCertificate property linking to the intermediate (and so on up the chain), as it did through v26.7.0 and as documented: "If the full certificate chain was requested, each certificate will include an issuerCertificate property containing an object representing its issuer's certificate."
What do you see instead?
The leaf is returned fully populated (subject, issuer, raw, fingerprints, etc.) but issuerCertificate is undefined. getPeerX509Certificate().issuerCertificate is also absent.
Additional information
I bisected nightly builds and v27.0.0-nightly20260808 (aed4eaf89d) is the last one before the break; v27.0.0-nightly20260809 (0700e749eb) is where it breaks. The only tls/crypto commit in that range is c59cd6b (#64677), "tls: fix authorized state on no-cert TLS1.3 client cert resumption", which added to onServerSocketSecure() in lib/internal/tls/wrap.js.
Calling getPeerX509Certificate() caches the peer certificate as a leaf only, so subsequent getPeerCertificate(true) returns the cached leaf only.
The underlying issue is really a caching interaction that predates #64677, which is that calling getPeerX509Certificate() poisons getPeerCertificate(true). It can be reproduced on the unaffected versios by inserting that call before reading the chain, i.e., insert req.socket.getPeerX509Certificate(); as the first line of the request handler in the repro above. #64677 tripped this by calling getPeerX509Certificate() internally.
There are sort of two issues here AFAICT: the caching interaction where getPeerX509Certificate() mutates the chain for a later getPeerCertificate(true), and #64677 using getPeerX509Certificate() just to test presence of a peer certificate (instead of a check that does not populate/replace the cached peer certificate).
This regression affects any server that inspects the client certificate chain on Node >= 26.8.0.
Version
v26.8.0and up.v26.7.0is unaffected. Reproduces on currentmain.Platform
Also reproduced on GitHub Actions
ubuntu-latest. OpenSSL is 3.5.7.Subsystem
tls
What steps will reproduce the bug?
On the server side of a mutual-TLS connection,
socket.getPeerCertificate(true)no longer returns the client certificate's issuer chain. The leaf is returned fully populated, butissuerCertificateisundefined, even though the client presented[leaf, intermediate]and the socket isauthorized.Here is a repo script that creates a root -> intermediate -> client chain with
openssl, then runs anode:httpsmTLS handshake and reads the peer certificate on the server:Compare output on an affected and an unaffected version:
How often does it reproduce? Is there a required condition?
Every time on Node >= 26.8.0. Requires
requestCert: true, a client certificate signed through at least one intermediate, and reading the chain withgetPeerCertificate(true)orgetPeerCertificatewithincludeChain.What is the expected behavior? Why is that the expected behavior?
getPeerCertificate(true)on the server should return the leaf with anissuerCertificateproperty linking to the intermediate (and so on up the chain), as it did through v26.7.0 and as documented: "If the full certificate chain was requested, each certificate will include anissuerCertificateproperty containing an object representing its issuer's certificate."What do you see instead?
The leaf is returned fully populated (subject, issuer,
raw, fingerprints, etc.) butissuerCertificateisundefined.getPeerX509Certificate().issuerCertificateis also absent.Additional information
I bisected nightly builds and
v27.0.0-nightly20260808(aed4eaf89d) is the last one before the break;v27.0.0-nightly20260809(0700e749eb) is where it breaks. The only tls/crypto commit in that range is c59cd6b (#64677), "tls: fix authorized state on no-cert TLS1.3 client cert resumption", which added toonServerSocketSecure()inlib/internal/tls/wrap.js.Calling
getPeerX509Certificate()caches the peer certificate as a leaf only, so subsequentgetPeerCertificate(true)returns the cached leaf only.The underlying issue is really a caching interaction that predates #64677, which is that calling
getPeerX509Certificate()poisonsgetPeerCertificate(true). It can be reproduced on the unaffected versios by inserting that call before reading the chain, i.e., insertreq.socket.getPeerX509Certificate();as the first line of the request handler in the repro above. #64677 tripped this by callinggetPeerX509Certificate()internally.There are sort of two issues here AFAICT: the caching interaction where
getPeerX509Certificate()mutates the chain for a latergetPeerCertificate(true), and #64677 usinggetPeerX509Certificate()just to test presence of a peer certificate (instead of a check that does not populate/replace the cached peer certificate).This regression affects any server that inspects the client certificate chain on Node >= 26.8.0.