DeepConcepts

Security / security / transport / pki

"The Certificate Is Valid" Is Four Separate Checks

The misconception

That certificate validation is one boolean the TLS library returns. It is four checks, they fail for different reasons, and the fourth — matching the hostname you asked for against the names in the certificate — is not part of the path validation algorithm in RFC 5280 and is not performed by every library unless the application supplies the expected name. A client that builds a perfect chain to a trusted root and never compares the hostname has proved that some real certificate authority vouched for some name, which is not the same as proving you are talking to the host you dialled.

15 min

When a TLS client says the certificate is valid it has made four separate decisions, and they can fail one at a time. Does a chain of signatures lead from this certificate to a root already in my trust store? Is every certificate on that chain inside its validity window? Has any of them been revoked? And does the name I typed appear in the certificate I got back? The fourth is the one that stops a connection reaching the wrong server, and it is the one that is not in the specification the other three come from.

Two definitions before the panel. A trust anchor — usually called a root — is a certificate your client already has on disk and believes without checking; validation succeeds by reaching one. A subjectAltName (SAN) is the extension inside a certificate that lists the names it speaks for, as DNS names or IP addresses.

Below is a client opening a connection. On the left is what the server presented and what the client asked for; on the right, the checks this client performs. Start on library defaults, no hostname supplied — chain and dates only — and leave everything legitimate. It connects. Now change the name in the certificate.

What the server presented
What this client checks
this client says
a conforming client says
stopped at
what the handshake proves
What is on the wire — all names and dates illustrative

    
Validation

check ran · nothing to check · continued without an answer · the gap this connection walked through

The combination to sit with is SAN api.other.test with the library preset. The chain is real. A genuine certificate authority signed it. Every date is in order and nothing is revoked. The connection is encrypted with a key nobody else has. And the certificate is for a different host, which the client never checked, because nothing in the path validation algorithm asks that question. Switch on the hostname check and the same certificate is refused.

Why hostname matching is a separate layer

Because it is specified in a different document, by different people, for a reason. RFC 8446 §4.4.2.4 — the TLS 1.3 specification, at the exact point where the client receives the server's certificate — says: "In general, detailed certificate validation procedures are out of scope for TLS (see [RFC5280])." TLS establishes an encrypted channel to whoever holds the private key for the certificate it was shown. Deciding whether that certificate is acceptable is somebody else's job.

RFC 5280 then defines path validation — the four per-certificate checks in §6.1.3 — and it says what it is for in §6: "Certification path processing verifies the binding between the subject distinguished name and/or subject alternative name and subject public key." It binds a name to a key. It does not know which name you wanted. It cannot: your intent is not on the wire.

That missing piece is RFC 9525, Service Identity in TLS, published in 2023 and replacing RFC 6125. Its §6.1.1 contains the sentence the whole layer rests on: "The client MUST construct a list of acceptable reference identifiers and MUST do so independently of the identifiers presented by the server." Independently. The name you check against comes from the URL the user typed or the configuration you loaded, never from the certificate — a check that reads its expected value out of the thing being checked is not a check.

So the layering is: TLS gets you a channel, RFC 5280 gets you a name-to-key binding, RFC 9525 gets you the assurance that the name is the one you asked for. Each is necessary. Skip the third and you have proved that a public certificate authority vouched for a name — and the public CA set will vouch for any name whose owner asks. That is precisely the shape of mistake in verifying a token's signature and not its claims: the cryptography is impeccable and answers a question you did not ask.

The three rules of name matching that catch people

A wildcard covers one label. RFC 9525 §6.3 permits exactly one wildcard character, only as the complete content of the left-most label, and says plainly that it "can only match one label in a reference identifier". So *.example.test matches api.example.test and does not match eu.api.example.test. This surprises people because DNS wildcards behave differently, and §6.3 calls that out explicitly. The panel's eu.api.example.test option is this case; the fix is a certificate for the deeper name, or a SAN entry listing it.

The Common Name is not a name. RFC 9525 §1.3 summarises the rule as "Do not include or check strings that look like domain names in the subject's Common Name." Modern clients enforce it, which is why a certificate generated by an old script — CN set, no SAN — is refused by everything now with a message about the common name being invalid. It is not a downgrade in trust; it is the removal of a field that never had defined matching semantics.

An IP address is a different kind of identifier. If the client dials https://203.0.113.10/, the reference identifier is an IP address and matching needs an iPAddress SAN entry (§6.4). A certificate with a hundred DNS names in it matches none of them. This is why health checks that hit a pod IP directly fail against a certificate that works perfectly for the service name.

One more that is not a matching rule but lands in the same bug report: wildcards are a blast radius. RFC 9525 §7.1 notes a wildcard certificate "automatically vouches for any single-label hostnames within their domain", which is convenient and also means the private key on your least-maintained host speaks for every sibling.

Revocation is the check that does not really work

RFC 5280 §6.1.3 (a)(3) requires the client to establish that, at the current time, the certificate is not revoked — "by obtaining the appropriate CRL, by status information, or by out-of-band mechanisms". A certificate revocation list (CRL) is a signed list of revoked serial numbers the client downloads; OCSP, the Online Certificate Status Protocol, is a per-certificate query to a responder run by the certificate authority.

Both have the same structural problem, and it is the one every distributed system has: the answer is a statement about the past, delivered over a network that can fail. What the specification does not say is what to do when the answer does not arrive. There are only two options and both are bad. Fail closed and a certificate authority's outage becomes your outage — for a responder serving a large fraction of the web, a global one. Fail open — soft-fail — and the check stops being a check, because an unreachable responder produces the same outcome as a good one.

Set the panel to revoked, turn revocation checking on, and leave fail-closed off. It is correctly refused. Now set the responder to no answer: the client proceeds. The gap between those two rows is the whole reason revocation is treated as advisory in practice, and it is why the industry's real answer has been to shorten certificate lifetimes instead — the same move, and for the same reason, as short access token lifetimes. Maximum lifetimes have been cut repeatedly by CA/Browser Forum ballot, and the logic is the one from the token lesson: a certificate that expires in weeks and cannot be recalled is a smaller problem than a two-year one that theoretically can. Check the current cap before quoting a number; it has moved several times and is scheduled to move again.

Two mitigations worth knowing by name. OCSP stapling has the server fetch its own status response and include it in the handshake, which removes the client's round trip and its privacy leak but not the soft-fail question, since a server can simply not staple. OCSP must-staple is a flag in the certificate itself saying a response must be present, which converts absence into a hard failure for that one certificate — the only form of fail-closed that does not make you depend on a responder being up for everybody else's certificates too.

Reading the failure you actually have

Four checks, four families of message. Sorting a failure into the right family first is most of the work.

  • Chain. unable to get local issuer certificate, x509: certificate signed by unknown authority, PKIX path building failed, self signed certificate in certificate chain. Either the server is not sending an intermediate or the client does not have the root. Those are opposite fixes on opposite machines.
  • Validity. certificate has expired, certificate is not yet valid. The second one is nearly always the client's clock.
  • Revocation. Rare, and when you see it, believe it.
  • Hostname. certificate is valid for X, not Y, hostname verification failed, ERR_CERT_COMMON_NAME_INVALID. The certificate is fine; it is for something else.

The one command that separates them, run from the machine that is failing:

openssl s_client -connect api.example.test:443 -servername api.example.test -showcerts </dev/null

Read three things in the output. How many certificates came back — if there is only one and it is not self-signed, the intermediate is missing and the server is the thing to fix. Verify return code at the bottom, with its number and text, which names the failing check directly. And the Subject Alternative Name block in the leaf, which is the only place the names live. Run the same command from a machine that works and diff the two; the difference is almost always the trust store or the clock, both of which are properties of the client rather than the certificate everyone is staring at.

Then the two things to check in your own code, because they are how the fourth check goes missing:

  • Any client that connects by IP address and passes the hostname separately. Service meshes, health checkers and connection pools do this. If the expected name is not handed to the TLS layer — as -servername, as an SNI value, as a HostnameVerifier, as server_hostname — the name is not being checked, and no error will tell you so.
  • Anything that disables verification with a comment about a staging environment. verify=False, InsecureSkipVerify: true, -k, a trust manager whose methods are empty, a HostnameVerifier that returns true. Each of these is the verification disabled preset in the panel. Read the readout for what the handshake then proves. The correct fix for an internal certificate authority is to add its root to the client's trust store, which is a configuration change, not a code change, and which leaves all four checks running.

Your service connects to https://internal-api.example.test/. The certificate chains to a public root, is in date, is not revoked, and its only SAN entry is internal-api.other-vendor.test. Your client builds the chain but was never given an expected hostname. What happens, and what does it mean?

Two directions from here: what to do when trusting the whole public CA set is a bigger decision than you meant to make — pinning, and its failure modes — and the same algorithm run in the other direction, where the server checks the client, in mutual TLS. And once the connection is trustworthy, the flow that runs over it: the authorization code grant.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.