You asked for versioned implementation evidence, so I traced the path in the dotnet/runtime source on the release/10.0 branch (commit 18874f0d9047ec03fdce1fdc32a7e168e2bc4735). In short, the design intent is clear and the code enforces it: with all three sources null, SslStream selects no certificate, and on Windows it tells Schannel not to pick one on its own either. There is one known Windows bug that breaks this, and its fix is not yet in a released .NET 10 build.
1. SocketsHttpHandler to SslStream
SocketsHttpHandler passes its SslOptions (an SslClientAuthenticationOptions) to SslStream.AuthenticateAsClientAsync. It makes a shallow clone first and adds no certificate source of its own (ConnectHelper.cs, line 43).
The "automatic" store selection you may have read about belongs to HttpClientHandler, not to SocketsHttpHandler. When HttpClientHandler.ClientCertificateOptions = ClientCertificateOption.Automatic, it installs a LocalCertificateSelectionCallback that picks an eligible certificate from the user's store (HttpClientHandler.cs, lines 275–282). A directly created SocketsHttpHandler doesn't do that.
2. Managed certificate selection
SslStream.SelectClientCertificate() (SslStream.Protocol.cs, line 319) only has these sources, checked in order:
-
ClientCertificateContext, if it isn't null
-
LocalCertificateSelectionCallback, if it isn't null
-
ClientCertificates, only when it isn't null and Count > 0
There is no other branch. It doesn't fall back to the certificate store. With all three null, it returns null.
On your null-versus-empty questions:
-
ClientCertificates = null vs. an empty collection: with no callback, both give the same result, because every branch that reads the collection requires Count > 0.
- No callback vs. a callback that returns null: both result in no certificate. One side effect to know about is that when a callback is set,
SslStream replaces a null ClientCertificates with an empty collection before it invokes the callback (_sslAuthenticationOptions.ClientCertificates ??= new X509CertificateCollection()).
3. The Windows Schannel credential
With no selected certificate, AcquireClientCredentials (line 575) acquires an outbound credential with no certificate. Both Windows credential paths set SCH_CRED_NO_DEFAULT_CREDS for clients:
-
SslStreamPal.Windows.cs, lines 305–310: the SCHANNEL_CRED path (TLS 1.2 and below)
-
lines 392–397: the SCH_CREDENTIALS path, which supports TLS 1.3
The Windows documentation for SCH_CRED_NO_DEFAULT_CREDS says it prevents Schannel from attempting to automatically supply a certificate chain for client authentication. SCH_CREDENTIALS (and so TLS 1.3) is used on Windows builds 18836 and later, except with EncryptionPolicy.NoEncryption (lines 24–26 and 237–239). Older builds use SCHANNEL_CRED. Either way, the flag is set on both TLS 1.2 and TLS 1.3 paths.
4. The exception: a known credential-cache bug on Windows
The moderator's link is the relevant caveat: dotnet/runtime#132563. On Windows, a client with none of the three sources set could send the certificate that a different SslStream in the same process had used through ClientCertificateContext (or a certificate chain). The cause was the shared Schannel credential cache.
- It was fixed in PR #132079, which was merged into
main (.NET 11) on 2026-08-12.
- The reporter confirmed that it still occurs on 10.0.11, 9.0.19 and 8.0.30, on both TLS 1.2 and TLS 1.3.
- The .NET 10 backport (PR #136622, tracked in #136532) is still open today, and the
release/10.0 branch doesn't contain the fix yet.
The precondition is another SslStream in the same process that presented a certificate. Your design loads no certificates, so your code never creates that condition. But a library or another component in the same process could. If that matters for your guarantee, either run this client in a process that never does mutual TLS, or wait for a .NET 10 servicing release that includes PR #136622. Watch that PR to see which release it ships in.
5. Verifying it on your build
Because this is implementation behavior, not a documented contract, test it on the exact runtime you ship. Run a test server that requests but doesn't require a client certificate (for example, Kestrel with ClientCertificateMode.AllowCertificate), and log whether a certificate arrives (HttpContext.Connection.ClientCertificate). Do this for TLS 1.2 and TLS 1.3, and in a process that also performs a ClientCertificateContext handshake first, to cover the bug above.
Note that I traced this in source and haven't run it on Windows. The file and line references are to that commit, so they stay valid when the branch moves.
If this answers your question, please consider marking it as the accepted answer so others with the same requirement can find it.