TLS goals, the TLS 1.3 handshake, certificate chains and validation, SNI and ALPN, mutual TLS, and safe diagnosis of certificate errors in .NET.
HTTPS is HTTP inside TLS. TLS gives confidentiality (encryption), integrity (tamper detection) and server authentication (certificates).
TLS 1.3 handshake (one round trip): the client sends supported ciphers, a key share, SNI (hostname) and ALPN (h2); the server replies with its key share, certificate chain and a signature proving it owns the private key. Both derive session keys via ECDHE (forward secrecy).
The client validates: chain to a trusted root, not expired, hostname matches the SAN, and optionally revocation.
For "remote certificate is invalid": read the SslPolicyErrors / chain status, inspect with openssl s_client -showcerts, then fix the real cause (expired cert, missing intermediate, wrong hostname, or a private/corporate root CA not trusted in the container). Never return true from ServerCertificateCustomValidationCallback in production.
Client Server
│── ClientHello: versions, ciphers, key_share, │
│ SNI=api.example.com, ALPN=[h2, http/1.1] ──────────►│
│ │
│◄── ServerHello: chosen cipher, key_share ──────────────│ both sides now derive keys
│◄── {EncryptedExtensions: ALPN=h2} │
│◄── {Certificate: leaf + intermediates} │
│◄── {CertificateVerify: signature with private key} │
│◄── {Finished} │
│ client validates chain, hostname, expiry │
│── {Finished} + first HTTP request (encrypted) ─────────►│
TLS 1.2 needed two round trips; TLS 1.3 needs one, and resumption can allow 0-RTT (replay-prone, so only for idempotent requests).
| Check | Common failure |
|---|---|
| Chain builds to a trusted root | Server sends only the leaf; private/corporate CA not in the trust store |
| Validity dates | Expired certificate (or wrong clock on the client) |
| Hostname matches SAN | Calling by IP or internal alias not in the certificate |
| Key usage / EKU | Certificate not issued for server authentication |
| Revocation (OCSP/CRL) | Revocation server unreachable from a locked-down network |
In .NET you see SslPolicyErrors.RemoteCertificateChainErrors, RemoteCertificateNameMismatch or RemoteCertificateNotAvailable, and X509ChainStatus details like UntrustedRoot, PartialChain or NotTimeValid.
A service worked on developer laptops but failed in its Linux container with AuthenticationException: The remote certificate is invalid because of errors in the certificate chain: UntrustedRoot when calling an internal API. The API used a certificate from the company's private CA. Windows laptops trusted that root through Group Policy; the Debian-based container did not. A developer's proposed fix was ServerCertificateCustomValidationCallback = (_, _, _, _) => true, which would have allowed any man-in-the-middle. Instead we added the root CA to the image (COPY corp-root.crt /usr/local/share/ca-certificates/ + update-ca-certificates) and later moved the internal API behind a certificate from a CA trusted everywhere.
Checks real understanding of TLS and the discipline to fix certificate issues safely.
Green flags
Red flags
| Goal | Mechanism |
|---|---|
| Confidentiality | Symmetric encryption (AES-GCM, ChaCha20-Poly1305) with session keys |
| Integrity | AEAD tags on each record |
| Server authentication | Certificate chain + signature with the server's private key |
| Forward secrecy | Ephemeral (EC)DHE keys: stealing the server key later does not decrypt old traffic |
| Client authentication (optional) | Mutual TLS: client presents a certificate too |
Root CA (in OS trust store, self-signed, long-lived)
│ signs
▼
Intermediate CA (sent by the server)
│ signs
▼
Leaf certificate: CN/SAN = api.example.com, valid 2026-09-01 → 2026-11-30
Servers must send the leaf and intermediates. Some clients fetch missing intermediates via the AIA URL, others do not, so "works in the browser" is not proof.
h2 vs http/1.1 inside the handshake. HTTP/2 over TLS needs it.client ─TLS─► CDN/WAF ─TLS─► load balancer ─(TLS or plain)─► Kestrel
Each hop has its own certificate and expiry date. Know which hop presents which certificate, and monitor all of them.
HttpClient uses the OS trust store (Windows certificate store, OpenSSL's store on Linux, Keychain on macOS).SslClientAuthenticationOptions.CertificateChainPolicy (or X509ChainPolicy with CustomTrustStore and TrustMode = CustomRootTrust) lets you trust a specific private root for one client without trusting it machine-wide.Kestrel:Certificates:Default) and can reload them; many teams terminate TLS at the ingress instead.openssl s_client -connect api.internal:443 -servername api.internal -showcerts </dev/null
openssl x509 -in leaf.pem -noout -subject -issuer -dates -ext subjectAltName
curl -v https://api.internal/healthz # shows the verify result
openssl s_client against a public site and identify leaf, intermediate and root.expired., wrong.host., untrusted-root., incomplete-chain.) from a .NET console app and print SslPolicyErrors and chain statuses in a logging-only validation callback.openssl, issue a server cert, and make a .NET client trust only that root using CustomTrustStore.// Log the real reason for a TLS failure (diagnostics only: still returns the default decision)
var handler = new SocketsHttpHandler
{
SslOptions = new SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = (sender, cert, chain, errors) =>
{
if (errors != SslPolicyErrors.None)
{
logger.LogError("TLS validation failed: {Errors}; subject {Subject}; chain {Chain}",
errors, cert?.Subject,
string.Join(", ", chain?.ChainStatus.Select(s => s.Status) ?? []));
}
return errors == SslPolicyErrors.None; // never 'return true' unconditionally
}
}
};
// Trust a specific private root CA for ONE client, without changing the machine trust store
var corpRoot = X509CertificateLoader.LoadCertificateFromFile("/etc/certs/corp-root.crt");
var policy = new X509ChainPolicy
{
TrustMode = X509ChainTrustMode.CustomRootTrust,
RevocationMode = X509RevocationMode.NoCheck // set according to your PKI's revocation support
};
policy.CustomTrustStore.Add(corpRoot);
var internalHandler = new SocketsHttpHandler
{
SslOptions = new SslClientAuthenticationOptions { CertificateChainPolicy = policy }
};
var internalClient = new HttpClient(internalHandler) { BaseAddress = new Uri("https://ledger.corp.internal/") };
# Or trust the corporate root in the container image (affects all clients in the image)
FROM mcr.microsoft.com/dotnet/aspnet:10.0
USER root
COPY corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
RUN update-ca-certificates
USER $APP_UID
ServerCertificateCustomValidationCallback = (...) => true or DangerousAcceptAnyServerCertificateValidator. It turns off authentication, so anyone on the path can impersonate the server and read or change traffic. Instead: find the actual SslPolicyErrors and chain status, then fix the certificate, the chain or the trust store.openssl s_client -showcerts that the server sends the full chain.TLS problems are usually PKI operations problems, not cryptography problems: expiry, incomplete chains, trust stores that differ between environments, and names that do not match. The senior response is to automate certificate lifecycle, monitor expiry on every hop (CDN, gateway, ingress, service), and make trust explicit, ideally per client with CustomTrustStore for private CAs, rather than widening machine trust.
I treat any disabled validation as a security incident in code review, because it silently removes authentication. Where both sides need identity (service-to-service in zero-trust networks), I use mutual TLS, often through a service mesh so applications do not manage certificates themselves. And I remember the performance side: TLS 1.3 and connection reuse keep handshake cost low.
CONNECT. HttpClient picks it up from system settings or HTTP_PROXY/HTTPS_PROXY/NO_PROXY.Behind a reverse proxy, the app sees the proxy's IP and often plain http. The proxy passes the originals in X-Forwarded-For and X-Forwarded-Proto, but ASP.NET Core only applies them via the ForwardedHeaders middleware, and by default only trusts proxies on loopback. So UseHttpsRedirection sees http, redirects to https, the proxy forwards as http again: a loop.
Fix: UseForwardedHeaders first, configured with the proxy's IPs or network in KnownProxies/known networks. Do not trust all sources, or clients can spoof their IP and scheme.
api.example.com to an IP address (OS cache, then a resolver).api.example.com (TLS 1.3: one more round trip).GET /orders/42 with headers; often a load balancer or reverse proxy terminates TLS and forwards to an app instance.HttpClient keeps alive and reuses for the next request, skipping steps 1–4.On a cold connection, DNS + TCP + TLS can cost more than the request itself, which is why connection reuse matters.
Layered from outside in:
Plus observability at every hop (flow logs, edge logs, traces with traceparent), and automated certificates and network rules as code.
How to tell it (STAR)
**S:** After moving [billing] to Linux containers, every call to the internal [ledger API] failed with a certificate error. **T:** I had to restore it quickly without weakening security. **A:** The chain status said UntrustedRoot, and `openssl` showed our private CA, which Windows trusted via policy but the container did not. I blocked a quick fix that accepted any certificate, added the corporate root to the image, and later scoped trust to just the ledger client. **R:** Calls recovered the same day, and the analyser rule has since stopped [two] attempts to disable validation.
Last updated 23 Oct 2026.