Debuggingtlscertificatesanexpiredintermediate

TLS Debugging: Why the Certificate Is "Invalid"

"Certificate invalid" is the browser’s summary of six unrelated faults — wrong name, expired, missing intermediate, wrong clock, wrong certificate for the SNI, protocol or cipher mismatch — and openssl s_client plus curl -v name which one in a single line each.

ConceptualLinuxBrowserRuntime-specific
Interview question
Progress

The problem

The browser shows NET::ERR_CERT_AUTHORITY_INVALID, the Java service throws PKIX path building failed, the mobile app works, and the certificate was renewed yesterday. Which of the six things that produce "invalid" is it?

Six causes behind one error

Certificate verification is a sequence of checks, each of which fails with its own reason: does a name in the certificate’s Subject Alternative Names match the host I asked for; is the current time within notBeforenotAfter; can I build a chain from this certificate through intermediates to a root I trust; is the chain valid at each link (signatures, key usage, name constraints); is it revoked (rarely checked strictly); and, before any of that, could we agree on a protocol version and cipher at all. The browser flattens all of this into a red page; the tools do not.

Two of the six are not about the certificate at all. A wrong system clock makes every certificate look expired or not-yet-valid — on a device whose battery died, a VM restored from a snapshot, or a container with a frozen time source — and the symptom is "every HTTPS site is broken", which is the tell. A protocol or cipher mismatch happens before certificates are exchanged: a server that only offers TLS 1.2/1.3 against a client stuck on TLS 1.0 (old Android, Java 7, .NET 4.5 defaults) fails with handshake_failure or protocol_version, and the certificate is never seen.

Cause → what the browser says → what openssl / curl say → fix
CauseChromeopenssl s_client / curlFix
SAN does not include the hostnameNET::ERR_CERT_COMMON_NAME_INVALIDverify error 62 hostname mismatch (with -verify_hostname); curl (60) no alternative certificate subject name matches target host nameIssue a cert covering the name; check wildcards cover only one label
Expired / not yet validNET::ERR_CERT_DATE_INVALIDverify error 10 certificate has expired / 9 not yet valid; curl (60) certificate has expiredRenew; automate renewal and alert 14 days out
Missing intermediateNET::ERR_CERT_AUTHORITY_INVALID — but often works in Chromeverify error 20 unable to get local issuer certificate / 21 unable to verify the first certificate; Java PKIX path building failedServe the full chain (leaf + intermediates), not just the leaf
Wrong clock on the clientEvery site: NET::ERR_CERT_DATE_INVALIDexpired / not yet valid on every hostFix NTP; check date first when everything fails
Wrong certificate for SNIname mismatch, or the default vhost’s certs_client without -servername shows the default cert; with it, the right oneFix the vhost / ingress mapping; ensure clients send SNI
Protocol / cipher mismatchERR_SSL_VERSION_OR_CIPHER_MISMATCHalert handshake_failure (40) or protocol_version (70); no certificate printedEnable TLS 1.2 on the client, or accept that the old client is out of support

Reading openssl s_client

Linux

openssl s_client -connect host:443 -servername host opens a TCP connection, performs a TLS handshake with SNI set to host, prints the certificate chain the server sent, the verification result, and the negotiated protocol and cipher, then waits for input (type Q or Ctrl-C). Add -verify_hostname host to make it check the SAN like a browser would (older versions do not by default), -showcerts to dump every certificate in PEM, and -tls1_2 / -tls1_3 / -cipher to test a specific client capability. -CAfile points at a custom trust store — the way to test a private CA.

The two lines that matter are Verify return code at the bottom and the Certificate chain block at the top. The chain lists what the server sent, numbered from the leaf: s: is the subject, i: the issuer. If the last entry’s issuer is not a root in your store and there is no further entry, the intermediate is missing. Verify return code: 0 (ok) is the goal; any other number is one of the causes above.

A server that sends only the leaf: verify error 20; Chrome would likely fetch the intermediate itself and show a padlock
$ openssl s_client -connect api.example.com:443 -servername api.example.com -verify_hostname api.example.com </dev/null
CONNECTED(00000003)
depth=0 CN = api.example.com
verify error:num=20:unable to get local issuer certificate
verify error:num=21:unable to verify the first certificate
---
Certificate chain
 0 s:CN = api.example.com
   i:C = US, O = Let's Encrypt, CN = R11          <- issuer is an intermediate, and it is not in the chain
---
SSL handshake has read 1834 bytes and written 393 bytes
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Verify return code: 21 (unable to verify the first certificate)

$ echo | openssl s_client -connect api.example.com:443 -servername api.example.com 2>/dev/null \
    | openssl x509 -noout -dates -ext subjectAltName
notBefore=Aug 14 09:12:31 2026 GMT
notAfter=Nov 12 09:12:30 2026 GMT
X509v3 Subject Alternative Name:
    DNS:api.example.com, DNS:www.example.com

The missing intermediate: works in Chrome, fails in curl and Java

Runtime-specific

A server is supposed to send the leaf certificate and every intermediate up to (but not including) the root. When an operator installs only the leaf, verification depends on whether the client can find the intermediate on its own. Chrome, Safari and Windows (via CryptoAPI) implement AIA fetching: they read the Authority Information Access URL in the leaf, download the intermediate, and verify successfully — and Chrome also carries a bundled set of known intermediates. OpenSSL-based clients (curl, Python requests, PHP, Ruby), Go, and Java (by default) do not fetch; they see a chain ending at an unknown issuer and fail. The same site is green in the browser and red in every backend integration, monitoring probe and mobile SDK.

It is also intermittent in a maddening way: a client that recently verified another site using the same intermediate may have it cached (OpenSSL does not, but browsers and the Windows store do), so "works on my machine" is literally true. The test is openssl s_client -showcerts counting the chain, or an online SSL checker reporting "chain issues: incomplete". The fix is server configuration: fullchain.pem, not cert.pem, in nginx’s ssl_certificate; both SSLCertificateFile and the chain in Apache; the full chain in the Kubernetes TLS secret. Certificates and the Chain of Trust explains the chain of trust that this is breaking.

  • Browsers and Windows fetch missing intermediates (AIA); OpenSSL, Go and Java do not by default.
  • Serve leaf + intermediates; never the root (it is ignored, and wastes bytes in every handshake).
  • Java: PKIX path building failed: unable to find valid certification path to requested target = untrusted root or missing intermediate; keytool -list -cacerts shows the trust store.

SNI and the wrong certificate

One IP address serves many hostnames, and the server picks a certificate based on the Server Name Indication extension the client sends in the ClientHello. If the client sends no SNI (some very old clients, some health checkers, openssl s_client without -servername), the server serves its default virtual host’s certificate — often the first one alphabetically, or a self-signed placeholder — and the client sees a name mismatch. If the ingress or vhost mapping is wrong, the right SNI gets the wrong certificate. Both look identical to the browser: "this certificate is for other.example.com".

The test is to vary SNI and watch the certificate change: openssl s_client -servername a.example.com vs -servername b.example.com vs none. When testing a specific backend by IP, curl --resolve api.example.com:443:IP https://api.example.com/ keeps SNI and Host correct, whereas curl https://IP/ sends the IP as SNI (or none) and reports a mismatch that has nothing to do with the real problem. CDNs and TLS-terminating load balancers add one more certificate to keep straight: the one the client sees belongs to the edge; the origin’s certificate is verified (or not) by the edge, and a misconfigured origin shows up as a 5xx from the edge — see HTTP Debugging: 502, 503 and 504 Are Different Failures.

  • No SNI → default vhost cert. Test with and without -servername.
  • Testing by IP without --resolve produces a mismatch that is an artefact of the test.
  • Behind a CDN, the client-visible certificate is the CDN’s; origin problems surface as 502/526-style errors, not browser certificate errors.

Reading curl -v for TLS

curl -vI https://host/ prints the TLS negotiation in * lines between the connect and the request. SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 names the protocol and cipher; subject, start date, expire date, issuer describe the leaf; subjectAltName: host "x" matched cert’s "x" is the name check; SSL certificate verify ok. is the chain check. When it fails, curl stops with curl: (60) SSL certificate problem: … and the reason from OpenSSL, or (35) for handshake-level failures such as protocol mismatch. curl -k disables verification and is useful for one thing only: proving that everything else works while you fix the certificate. Never leave it in a script.

Distinguish (60) from (35): 60 means the handshake completed enough to receive a certificate that then failed verification; 35 means the handshake itself failed, which points at protocol, cipher, SNI routing to a dead backend, or a server that speaks plain HTTP on the port.

  • curl: (60) = certificate rejected → name, dates, chain, clock.
  • curl: (35) = handshake failed → protocol version, ciphers, wrong service on the port.
  • curl --cacert my-ca.pem tests a private CA; curl -k only proves the layers above TLS.

Key points

  • "Certificate invalid" is six faults: SAN mismatch, expiry, missing intermediate, wrong clock, wrong cert for SNI, protocol/cipher mismatch.
  • openssl s_client -connect host:443 -servername host shows the served chain and a numbered verify error; 0 is ok, 10 expired, 20/21 missing issuer, 62 hostname.
  • Browsers fetch missing intermediates via AIA; curl, Go and Java do not — serve the full chain.
  • A wrong system clock breaks every site; check date when everything fails at once.
  • SNI selects the certificate: test with and without -servername, and use curl --resolve rather than connecting by IP.
  • curl (60) is a verification failure; (35) is a handshake failure before verification.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why does the browser collapse six causes into one page?

Users cannot act on any of them and the safe action is the same: do not proceed. Engineers need the reason, and the tools give it; the browser’s advanced details usually do too.

Why must the server send intermediates?

Trust stores contain roots only, and roots are kept offline; intermediates sign leaves. Without the intermediate the client cannot connect the leaf to any root. AIA fetching is a browser convenience, not part of the protocol contract.

Why does SNI exist?

The certificate must be chosen before any encrypted data flows, but the HTTP Host header is inside the encrypted stream. SNI carries the hostname in the clear in the ClientHello so a shared IP can serve many certificates.

How it fails

What the failure looks like from inside real software.

  • Monitoring checks HTTP on port 80 only; the certificate expires and every HTTPS client fails while the dashboard stays green.
  • A Kubernetes TLS secret created from cert.pem instead of fullchain.pem: browsers work, every backend-to-backend call and mobile SDK fails with an unknown issuer.
  • Renewing a wildcard *.example.com and discovering it does not cover api.internal.example.com (wildcards match one label).
  • A VM restored from a snapshot with a clock three months behind, reporting every certificate as not yet valid.
  • Testing an origin by IP without --resolve, seeing a mismatch, and "fixing" a certificate that was fine.
  • curl -k left in a deployment script, silently accepting a hijacked or expired endpoint for months.