openssl s_client: what a browser's padlock icon is hiding from you

When a browser shows a certificate error, it collapses a dozen different possible causes into one blocking warning page and gives you almost nothing to act on. openssl s_client does the opposite: it opens a raw TLS connection to a server and prints exactly what it received — the full certificate chain, who issued it, when it expires, and whether the handshake actually succeeded — before any browser-side policy decides whether to trust it.

The basic form: -connect host:port

openssl s_client -connect example.com:443

This opens a TLS connection to port 443 and immediately dumps the handshake result: the certificate chain the server sent, the negotiated protocol version and cipher, and a Verify return code line at the very end that tells you in plain terms whether the chain validated (0 (ok)) or why it didn't. In a real terminal, openssl s_client then leaves the connection open waiting for you to type raw protocol bytes by hand (classic use: typing an HTTP request directly to test a server manually). Here it runs non-interactively — stdin is closed immediately after connecting — so what you get is exactly the handshake and certificate output, which is almost always what you actually came for.

For an IPv6 target, the address needs brackets around it, same as the URL form: -connect [2606:2800:220:1:248:1893:25c8:1946]:443 — without the brackets, the colons inside the address itself are indistinguishable from the host:port separator.

Reading the certificate block

The part worth reading closely is the Certificate chain section near the top of the output. Each entry shows:

  • subject — who the certificate was issued to (the domain, typically as a Common Name or Subject Alternative Name).
  • issuer — who issued it (the certificate authority, e.g. Let's Encrypt, DigiCert).
  • Further down, notBefore / notAfter dates — the certificate's validity window.

A chain normally has more than one entry: the leaf certificate for the actual domain, plus one or more intermediate certificates linking it back to a root CA your system already trusts. If only the leaf certificate shows up and nothing links it to a trusted root, that's an incomplete chain — a very common misconfiguration where a server was set up with just its own certificate and never given the intermediate bundle its CA requires. Browsers sometimes paper over this by caching intermediates they've seen elsewhere; openssl s_client won't, which is exactly why it catches incomplete-chain problems that "it works in my browser" doesn't.

-showcerts: seeing the whole chain in full

By default, openssl s_client prints the chain's subject/issuer summary but only the full PEM-encoded certificate body for the leaf. Adding -showcerts prints the complete PEM block for every certificate in the chain, not just the first — useful when you specifically need to inspect an intermediate certificate itself (checking its own expiry, or confirming it's the one your CA currently expects), not just know that it exists.

-servername: why the wrong certificate can come back

Modern TLS uses SNI (Server Name Indication) — the client tells the server which hostname it's trying to reach during the handshake itself, before any HTTP request is sent, because a single IP address commonly hosts many unrelated domains behind the same load balancer or CDN edge. Without SNI, the server has no way to know which of those domains' certificates to present, and usually falls back to a default — which is very often not the one you meant to check.

openssl s_client -connect 203.0.113.10:443 -servername example.com

This connects to the IP directly but tells the server "I'm asking for example.com specifically" — the certificate that comes back is the one that domain actually serves, not whatever the server's default happens to be. This combination — connecting by IP while setting -servername to the real hostname — is exactly how you check "is this the right server serving the right certificate" independently of DNS, which matters when you're debugging a load balancer with several backend IPs and want to confirm each one individually is configured correctly.

The one line that answers "is this actually a certificate problem"

Skip straight to the end of the output for Verify return code. A few you'll see often:

  • 0 (ok) — the chain validated against a trusted root. If something is still failing above the TLS layer, the certificate itself isn't the cause.
  • 10 (certificate has expired) — exactly what it says; check the notAfter date in the chain block above to see by how much.
  • 19 (self-signed certificate in certificate chain) / 18 (self-signed certificate) — no path to a publicly trusted root exists at all, expected for internal/dev servers, a red flag for anything public-facing.
  • 21 (unable to verify the first certificate) — almost always the incomplete-chain problem described above: the leaf is fine, but the intermediate that should link it to a trusted root is missing.

This one line is usually faster than reading the full chain by hand, and it's the first thing worth checking before anything else.

openssl s_client vs curl

They answer different layers of the same failed request, and conflating them wastes a debugging cycle. curl -I https://example.com tells you whether an HTTPS request succeeded end to end — TLS handshake and HTTP response together — but when it fails on the TLS step, curl's own error message about it is comparatively terse. openssl s_client -connect example.com:443 isolates just the TLS layer: it tells you specifically why the handshake failed — expired, self-signed, incomplete chain, wrong SNI — independent of whatever HTTP response would have followed. When curl fails with something certificate-shaped, openssl s_client against the same host is usually the very next command to run.

A couple of real scenarios

"Users report a certificate warning, but it looks fine when I check it." You're probably checking a different edge than they are. Run -connect against a couple of the service's known IPs directly with -servername set to the real hostname — a load balancer with one misconfigured backend behind it is a common cause of "works for me, broken for some users."

"The site was working yesterday and now the browser refuses to load it." Check Verify return code first. 10 (certificate has expired) with a notAfter date of yesterday is an extremely common outcome of an automated renewal (Let's Encrypt or otherwise) silently failing a cycle or two before anyone notices.

Run it yourself against any host you control — the full chain and the verify code are usually enough to tell you which of these you're actually looking at.

Run `openssl s_client` in the terminal →