Measurement · August 13, 2026
Three of five sent it anyway
One certificate chain the client had said it would not accept, sent by three of five TLS server stacks. All three are conformant with RFC 8446, and what decides which chain goes out is the client's preference order rather than the configuration file.
On 2026-08-10 I gave five TLS servers one certificate chain: an EC leaf key under an ML-DSA-44-signed intermediate. Then I connected with a client that said, in both of the extensions that carry the question, that it would accept ECDSA signatures and nothing else. It could use the leaf’s key. It had stated it would not accept ML-DSA signatures on certificates, which is what every certificate in that chain carries.
OpenSSL and nginx refused the handshake. Caddy, rustls, and Envoy sent the chain.
All three are conformant with RFC 8446. That sentence goes here rather than at the end, because holding it back for a reveal would earn a correction in the replies and deserve it.
If you are planning a post-quantum migration and expect signature_algorithms_cert to keep the wrong chain off the wire, the specification does not promise you that.
What the text actually says
Section 4.4.2.2 of RFC 8446 tells a server that all certificates it provides MUST be signed by an algorithm the client advertised, and then adds the clause that decides this whole question: “if it is able to provide such a chain”. A server that cannot produce one SHOULD continue the handshake by sending a certificate chain of its choice.
In the cell above there is one configured chain and the client excluded it, so the fallback applies directly. Serving is what the text recommends. Refusing with handshake_failure is the behavior that sits less comfortably with it.
Go’s crypto/tls declines the check in a source comment that cites the SHOULD by name. From common.go, inside SupportsCertificate, at go1.26.0:
Note we don’t currently support certificate_authorities nor signature_algorithms_cert, and don’t check the algorithms of the signatures on the chain (which anyway are a SHOULD, see RFC 8446, Section 4.4.2.2).
So Caddy’s behavior is a documented decision, made deliberately, years before I ran anything. Nobody is broken. That is the problem.
The failure this is about, and who put it on the record
The interesting case is not the one I measured. It is the one it points at.
Configure a server with a classical chain and a post-quantum chain, which is the deployment shape every phased migration needs. Now the server has a choice to make on every connection, and if it makes the wrong one nothing tells you. The handshake completes, the client validates, the page loads, and the server has been handing the classical chain to post-quantum-capable clients for months. No monitoring signal separates that from a migration that worked.
That is not hypothetical and it is not mine. OpenSSL issue #32221, filed 2026-08-06 by LiD0209, records a dual-chain server completing a handshake on a chain it had already reported as CA signature: NOT OK, with a compatible chain configured beside it and captured output in the issue. That case is classical, ECDSA against RSA, on one implementation. Two companion issues from the same batch cover adjacent behavior. What was missing was the same question asked across implementations, with real post-quantum chains.
The setup is not new either. Frauenschläger and Mottok call multiple-chain deployment selected by signature_algorithms_cert “the simplest and already practically used approach”, and name OpenSSL as already supporting it. The configuration is established practice and the OpenSSL result is theirs. What I added is the other four stacks.
Be precise about “silent”
The single-chain cell I opened with is not the silent failure. It is worth separating the two, because conflating them is the easiest way to overstate this.
When rustls served the excluded chain to a client doing real root verification, the client rejected it and the server got a fatal alert:
client: handshake FAILED x509: certificate signed by unknown authority
server: handshake FAILED received fatal alert: BadCertificate
Both connections fail. The difference is where the decision happens. OpenSSL declines before sending, because it was told what the client would accept. rustls sends, and finds out from the alert. Only one of those failed for a reason the server could have known in advance.
The “handshake OK” verdicts in my matrix come from the probe running in capture-only mode, which reads the chain off the wire without verifying it against roots. That mode exists to answer one question: what did the server send? It sent the chain the client excluded.
The silent version needs two chains configured, and that is where the scoping gets tight. Read the limits below before quoting any of this.
Why rustls cannot honor it, even if you want it to
This is source reading rather than a handshake, and it is the section that earns the piece among engineers.
A rustls certificate resolver implements ResolvesServerCert::resolve, which receives a ClientHello. In released 0.23.43 that struct carries eight fields: server_name, signature_schemes, alpn, server_cert_types, client_cert_types, cipher_suites, certificate_authorities, and named_groups.
signature_algorithms_cert is not one of them. In the whole crate the identifier appears twice, both in msgs/enums.rs, as the code point 0x0032 and as a member of the list of extensions compressible inside an ECH inner ClientHello. It is never parsed into a payload struct, so it never reaches anything that could act on it.
A custom resolver cannot fix this. It is not a policy an application can override, it is an absence in the parser. Printed from inside a running resolver, on the cell above:
resolver.signature_schemes = [ECDSA_NISTP256_SHA256]
resolver.certificate_authorities = None
resolver.signature_algorithms_cert = <NO ACCESSOR EXISTS>
This is not a blanket posture toward selection hints. certificate_authorities, from the same paragraph of the same RFC section, is parsed, exposed, documented with an RFC link, and in dev HEAD carries a deliberate carve-out hiding it from the resolver on TLS 1.2 because the RFC says so. Somebody thought carefully about this neighborhood and implemented the extension next door.
And the gap does not close when post-quantum support lands. I measured three rustls builds: ring, the default aws_lc_rs, and rustls-post-quantum 0.2.4 with the aws-lc-rs-unstable feature. The third one loads and serves an ML-DSA chain that the first two cannot parse at all, and its resolver correctly reports signature_schemes = [ML_DSA_44]. It still has no way to ask what the client accepts on certificates. The selection gap is independent of post-quantum maturity, not an early symptom of it.
None of this is a gap the maintainers are unaware of in general terms. rustls#2417 records their stated position on post-quantum signature support in core providers, with reasoning, gated on CA/Browser Forum ballots, and rustls#2577 reports the ML-DSA key-loading failure more than a year before I reproduced it. Neither is about selection between two configured chains, which is why the finding survives, but “no results” for one search string is not “nothing is known”.
What decides selection, and it is not your config
I expected server configuration order to matter, because BoringSSL documents selecting the first usable credential from its list. On nginx it does not.
Same two chains, order swapped in ssl_certificate, client accepting both:
ssl_certificate order |
chain served |
|---|---|
| classical, then post-quantum | post-quantum |
| post-quantum, then classical | post-quantum |
Now hold the server still and change only the order inside the client’s signature_algorithms:
| client order | CertificateVerify | chain served |
|---|---|---|
0x0904, 0x0403 (ML-DSA first) |
2,428 bytes | ML-DSA |
0x0403, 0x0904 (ECDSA first) |
79 bytes | ECDSA |
The chain follows the client. That is correct under RFC 8446, and the size of the CertificateVerify message reports which leaf key was actually used, independent of anything the client claims afterward.
It also has a consequence worth saying flatly. An operator cannot steer which chain goes out by editing a configuration file. During a migration the client population decides, one connection at a time, and the operator’s job is to make sure both answers are correct rather than to pick one.
Why nobody has scanned for this
Here is the part I find most interesting, and it is the honest answer to “why has nobody published this?”
TLS 1.3 puts the client’s constraint in the clear and encrypts the server’s answer. signature_algorithms_cert is a ClientHello extension, listed as such in the RFC 8446 section 4.2 extension table and drawn without braces in Figure 1. The Certificate message is written {Certificate*}, and the legend defines those braces as protection under the handshake traffic secret. Section 4.4 says it in prose too.
So a passive observer sitting on the wire reads exactly which certificate algorithms the client was willing to accept, and cannot read which chain came back. The constraint is observable. Compliance with it is not.
That is not the same as undetectable, and the difference matters. Three parties can see it: an active client that sets the constraint and inspects what arrives, which is my method; anyone holding the handshake keys, including a TLS-terminating inspection proxy or an endpoint agent; and the server operator, who can instrument their own software. What none of those is, is a fleet-scale passive scan.
It explains a pair of numbers worth quoting together. Dubey and Varshney measured 32,011 domains in 2026 and found 49.3% supporting hybrid post-quantum key exchange against 0% using hybrid post-quantum certificates. Key exchange is negotiated in the plaintext ClientHello and ServerHello, so every readiness tool reports it. Certificates are encrypted, so none of them can. The half the industry can watch is the half that moved.
If you have seen a higher number for certificates, check what it is counting. DigiCert’s 2026 survey of 1,001 IT and security decision-makers reports that “Only 7% report that more than half of their certificates currently use quantum-safe or hybrid cryptography.” That is self-reported, it never defines “quantum-safe or hybrid”, and it counts an organization’s own certificates without scoping them to publicly trusted ones. The 0% is measured, and scoped to public web domains. Both can be true.
There is a citable admission from the incumbent, too. TLS-Anvil, the cross-implementation TLS 1.3 conformance suite covering 13 implementations, marks the certificate-selection requirements of section 4.4.2.2 out of scope in its annotations. Line 69 of TLS-Testsuite/annotations/out_of_scope/8446.txt is the dual-chain MUST itself, quoted in full in the file. The state of the art in conformance testing looked at this requirement and wrote down that it does not test it.
What I did not measure
The dual-chain question was answered on OpenSSL and nginx. It was not answered on the other three, and the reasons differ.
Caddy rejects a configuration carrying both a classical and a post-quantum chain and will not start. Envoy appears on the single-chain cells only: its dual-chain behavior is recorded as unresolved because the runner and my ad-hoc probes disagreed, and two of the diagnostic harnesses I was using to settle it turned out to be defective, one printing a pass while docker run never executed. Nothing is claimed about Envoy holding two chains, in either direction. Settling it takes a small server built against upstream BoringSSL, not more YAML.
For rustls the dual-chain claim rests on source reading rather than a two-chain handshake, and I have stamped it accordingly. The stronger form is the one I can defend: rustls 0.23.43 ships two server resolvers, SingleCertAndKey, which selects on nothing, and ResolvesServerCertUsingSni, which selects on hostname. A dual-stack migration serves two chains on the same hostname, so SNI cannot express it, and a custom resolver cannot see the extension.
Apache httpd and HAProxy are untested. Both wrap OpenSSL and answer a different question about whether a wrapper preserves its library’s behavior, which nginx did, exactly.
This is a bench, not a survey. I say “server software” throughout and never “production servers”, because with 0% hybrid post-quantum certificate adoption measured across those 32,011 domains there is no deployed dual-chain population to survey. Three claims were retracted from this project by self-audit on the day they were written, all three for the same mistake: reading a result measured on one build of a library as a property of the library.
Where this is heading anyway
A reviewer will raise this, so I will. Drop-in post-quantum certificates may not be the road the Web PKI takes.
Merkle Tree Certificates are running. Cloudflare serves them on 1000 proxied domains with Chrome as the client on 50% of Chrome Beta 146+, and measured them about 9% faster at P50, 105ms against 116ms, with classical signatures throughout. Let’s Encrypt calls MTC “the path forward for the post-quantum Web PKI” and is targeting a staging environment in late 2026 and production in 2027. Google Cloud’s roadmap puts Google Trust Services at 2028. There is an IETF working group chartered for it with a standards document due 2026-11-30.
That does not touch anything measured here, and it does change what the measurement is for. Every MTC in the Cloudflare experiment is backed by a traditional certificate chain, so the two-chain problem does not disappear when MTC arrives, it arrives alongside it. And the transition period is exactly when a server holds more than one chain and has to pick.
What to do with this
If you run OpenSSL or nginx, the extension works, the fallback is fail-closed, and your remaining problem is that you cannot steer selection from the config file. Test with a client that sends the constraint, because that is the only way you will see which chain went out.
If you run Caddy, rustls, or Envoy, the constraint is not part of the decision. That is conformant, it is documented in Go’s case, and it means a phased migration on those stacks needs a different mechanism: separate hostnames, separate listeners, or a resolver keyed on something the library does hand you.
Either way, do not put signature_algorithms_cert in the migration plan as a control. It is a hint that two of these five stacks treat as binding, and the specification agrees with the other three.
Limits
One machine, one day, all runs 2026-08-10. Versions pinned per cell and recorded rather than assumed, because OpenSSL #32028 is being actively worked and a fix would move the ground under these results: OpenSSL 3.5.5, nginx 1.31.3 on nginx:alpine with OpenSSL 3.5.7, Caddy built from source on go1.26.0, rustls 0.23.43 at tag v/0.23.43, and Envoy 1.36.9 on envoyproxy/envoy:v1.36-latest. The client is a purpose-built probe on a forked Go crypto/tls, because emitting the two extensions independently is something stock tooling will not do. Chain identity is read from server-side handshake message lengths rather than inferred from the client’s error text. Every passing cell has a negative control, one flipped byte inside a leaf signature that a verifying client rejects while the untampered chain passes, because a check that cannot fail is not a check. The deciding cell ran five consecutive times with identical results, and selfcheck.sh passed 12 of 12 on a clean rebuild.
Provenance
Measured here (Verified). The five-stack outcome on the deciding cell, the six OpenSSL dual-chain cells, the six mixed-shape cells, the nginx configuration-order result, the client-preference-order result with its 2,428-byte and 79-byte CertificateVerify values, the reproduction across all three rustls builds, and the negative control. Scripts and captured output are public at pqc-chain-selection (DOI 10.5281/zenodo.21911032), one script and one output file per cell under runners/evidence/, with per-cell results in MATRIX.md.
Read in source, not measured (Reported). The eight fields on rustls’s ClientHello, at tag v/0.23.43 and on dev HEAD 2cd5cc1. The Go crypto/tls comment, read in the installed go1.26.0 source. RFC 8446 section 4.4.2.2, the section 4.2 extension table, and the Figure 1 legend, quoted from the RFC text.
Cited, not claimed (Reported). The silent wrong-chain failure with runtime evidence is OpenSSL #32221. Multiple-chain deployment as established practice, and OpenSSL’s support for it, are Frauenschläger and Mottok, 10.1007/978-3-032-16089-8_30. “Mixed certificate chains” as a concept is Paul, Kuzovkova, Lahr, and Niederhagen, AsiaCCS 2022, 10.1145/3488932.3497755. The 32,011 domains, 49.3%, and 0% are Dubey and Varshney, arXiv:2606.16473. The 7% is The Quantum Execution Gap, DigiCert’s 2026 Quantum Readiness Outlook, whose methodology page records an independent survey by Propeller Insights in May 2026 of 1,001 IT and cybersecurity decision-makers across the United States (500), the United Kingdom (251), and Australia (250). The TLS-Anvil annotation is read from the file on main. The MTC deployment figures are Luke Valenta’s IETF 125 PLANTS slides; the Let’s Encrypt commitment is their post of 2026-06-03; the 2028 date is Google Cloud’s published roadmap.
Every document linked above was downloaded and searched rather than read through a summary, on 2026-08-13 for the DigiCert report and the IETF slides. DigiCert’s PDF sits behind Incapsula and returns an interstitial to non-browser clients, which is bot protection rather than a dead link.
Argued, not measured (Proposed). That the plaintext-request, encrypted-response asymmetry is why this went unreported. The mechanism is documented and the consequence follows, but no author has said this is what stopped them.
What this project got wrong along the way. Three claims were retracted by self-audit on 2026-08-10, each one generalizing a result measured on a single build into a statement about a whole library. Separately, two of the diagnostic harnesses used on the Envoy question printed plausible output while doing nothing, which is why the Envoy dual-chain result is withdrawn rather than reported. Both failures are in the repository on purpose.
The likeliest reason this went unmeasured is not that nobody looked. The question travels in the clear and the answer is encrypted.