Two commands answer most certificate questions: openssl s_client shows what a server sends during the TLS handshake, and curl -v shows what a client concludes from it. The captures below were taken with OpenSSL 3.6.3 (from Homebrew) and curl 8.7.1 on macOS. Most of the surprises come from which name you send, which tool you run, and which lines you trust.

One screen with the facts that matter

Live capture, 26 September 2026. Our own apex, fetched with OpenSSL and decoded with openssl x509:

openssl s_client -connect and.guide:443 -servername and.guide </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName -serial -fingerprint -sha256
subject=CN=and.guide
issuer=C=US, O=Google Trust Services, CN=WE1
notBefore=Aug  8 05:34:39 2026 GMT
notAfter=Nov  6 06:34:36 2026 GMT
X509v3 Subject Alternative Name: 
    DNS:and.guide
serial=5A0AEAFCEA57A24413F1E5755458809F
sha256 Fingerprint=6F:CF:0D:9D:44:E9:CF:BB:8C:14:E1:B1:9E:C7:24:90:CA:0C:85:57:D8:CA:96:77:FF:98:48:31:32:8D:CF:67
Field What it tells you In this capture
Subject Alternative Name The hostnames the certificate is valid for. Clients match against this list. Only and.guide, not www.and.guide
subject A legacy name field. RFC 9525 says the CN must not be used to identify the service. CN=and.guide
issuer The intermediate CA that signed the certificate Google Trust Services, WE1
notBefore / notAfter The validity window, always in GMT 8 August to 6 November 2026, a 90-day certificate
serial The CA’s identifier for this certificate; a renewal gets a new one 5A0A…
SHA-256 fingerprint A hash of this exact certificate The quickest way to tell whether two servers present the same certificate

</dev/null closes the connection once the handshake finishes, 2>/dev/null hides s_client’s verification chatter, and openssl x509 picks the server’s certificate out of the rest of the output.

The chain the server actually sends

Live capture, 26 September 2026. The same connection with -showcerts (certificate bodies trimmed):

openssl s_client -connect and.guide:443 -servername and.guide -showcerts </dev/null
Connecting to 172.66.47.12
depth=2 C=US, O=Google Trust Services LLC, CN=GTS Root R4
verify return:1
depth=1 C=US, O=Google Trust Services, CN=WE1
verify return:1
depth=0 CN=and.guide
verify return:1
CONNECTED(00000006)
---
Certificate chain
 0 s:CN=and.guide
   i:C=US, O=Google Trust Services, CN=WE1
   a:PKEY: EC, (prime256v1); sigalg: ecdsa-with-SHA256
   v:NotBefore: Aug  8 05:34:39 2026 GMT; NotAfter: Nov  6 06:34:36 2026 GMT
-----BEGIN CERTIFICATE-----
;; (trimmed)
-----END CERTIFICATE-----
 1 s:C=US, O=Google Trust Services, CN=WE1
   i:C=US, O=Google Trust Services LLC, CN=GTS Root R4
   a:PKEY: EC, (prime256v1); sigalg: ecdsa-with-SHA384
   v:NotBefore: Dec 13 09:00:00 2023 GMT; NotAfter: Feb 20 14:00:00 2029 GMT
-----BEGIN CERTIFICATE-----
;; (trimmed)
-----END CERTIFICATE-----
 2 s:C=US, O=Google Trust Services LLC, CN=GTS Root R4
   i:C=BE, O=GlobalSign nv-sa, OU=Root CA, CN=GlobalSign Root CA
   a:PKEY: EC, (secp384r1); sigalg: sha256WithRSAEncryption
   v:NotBefore: Nov 15 03:43:21 2023 GMT; NotAfter: Jan 28 00:00:42 2028 GMT
-----BEGIN CERTIFICATE-----
;; (trimmed)
-----END CERTIFICATE-----
---
...
Verification: OK
...

In each entry, s: is the subject, i: the issuer, a: the key and signature algorithm, and v: the validity window. Each certificate’s issuer should be the next certificate’s subject. Here the server sent three: the leaf, the WE1 intermediate, and a copy of GTS Root R4 signed by GlobalSign Root CA (a cross-signed root, for clients that trust GlobalSign but not GTS Root R4).

The depth= lines at the top show the path OpenSSL actually verified. It stopped at depth 2 with the GTS Root R4 already in its trust store, so it never needed the GlobalSign-signed copy. That difference is why the OpenSSL docs describe -showcerts as the list the server sent, in the order it sent them, and not as a verified chain. If the list stops at 0, the server is not sending its intermediate, and clients that do not already have that intermediate will fail to verify.

Protocol, cipher and key exchange

Live capture, 26 September 2026. -brief prints a short handshake summary; the second command forces TLS 1.2 to see what an older client would get:

openssl s_client -connect and.guide:443 -servername and.guide -brief </dev/null
openssl s_client -connect and.guide:443 -servername and.guide -tls1_2 </dev/null 2>/dev/null \
  | grep -E '^New,|^    Protocol|^    Cipher'
Connecting to 172.66.44.244
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Peer certificate: CN=and.guide
Hash used: SHA256
Signature type: ecdsa_secp256r1_sha256
Verification: OK
Negotiated TLS1.3 group: X25519MLKEM768
DONE
New, TLSv1.2, Cipher is ECDHE-ECDSA-CHACHA20-POLY1305
    Protocol  : TLSv1.2
    Cipher    : ECDHE-ECDSA-CHACHA20-POLY1305

TLS 1.3 was negotiated with AES-256-GCM, and the key exchange group was X25519MLKEM768, the hybrid of classic X25519 and post-quantum ML-KEM that Cloudflare supports for visitor connections. The server also still accepts TLS 1.2. The signature type (ecdsa_secp256r1_sha256) matches the certificate’s P-256 key from the chain above.

The cipher is a negotiation result, not a property of the site. curl on the same machine, built against LibreSSL, got a different TLS 1.3 cipher from the same server in the next capture. Compare protocol versions across tools, not cipher names.

What curl -v tells you

Live capture, 26 September 2026. curl’s verbose output for a HEAD request, keeping only the connection and TLS lines:

curl -svI https://and.guide/
* Host and.guide:443 was resolved.
* IPv6: (none)
* IPv4: 172.66.44.244, 172.66.47.12
*   Trying 172.66.44.244:443...
* Connected to and.guide (172.66.44.244) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/ssl/cert.pem
*  CApath: none
...
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256 / [blank] / UNDEF
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=and.guide
*  start date: Aug  8 05:34:39 2026 GMT
*  expire date: Nov  6 06:34:36 2026 GMT
*  subjectAltName: host "and.guide" matched cert's "and.guide"
*  issuer: C=US; O=Google Trust Services; CN=WE1
*  SSL certificate verify ok.
...
  • CAfile: /etc/ssl/cert.pem is the trust store this curl build uses. A different build or OS can trust a different set of roots.
  • SSL connection using lists protocol, cipher, key-exchange group and signature type. With this LibreSSL 3.3.6 build, curl does not fill in the last two, hence [blank] / UNDEF. Use OpenSSL’s -brief output for those.
  • subjectAltName: host "and.guide" matched is the hostname check, and SSL certificate verify ok. is the chain check. Both have to pass.
  • ALPN: server accepted h2 means the connection will speak HTTP/2.

SNI decides which certificate you get

Server Name Indication is the hostname a client sends in its first TLS message. Servers that host many sites use it to pick a certificate. OpenSSL’s s_client fills it in from -connect when you give it a hostname; -noservername turns it off.

Live capture, 26 September 2026. Our apex with SNI suppressed:

openssl s_client -connect and.guide:443 -noservername </dev/null
Connecting to 172.66.44.244
80E12DF501000000:error:0A000410:SSL routines:ssl3_read_bytes:ssl/tls alert handshake failure:ssl/record/rec_layer_s3.c:918:SSL alert number 40
CONNECTED(00000006)
---
no peer certificate available
---
...
Verification: OK
---
...
Verify return code: 0 (ok)
---
...

Cloudflare refused the handshake (alert 40) and sent no certificate at all, yet s_client still printed Verification: OK and Verify return code: 0 (ok). Those lines only mean no verification error was recorded. Check that a certificate was actually received before reading anything else, or add -verify_return_error so failures stop the handshake.

Live capture, 26 September 2026. Three well-known sites, each fetched with and without SNI:

for h in letsencrypt.org www.google.com github.com; do
  echo "== $h with SNI"
  openssl s_client -connect $h:443 -servername $h </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer
  echo "== $h without SNI"
  openssl s_client -connect $h:443 -noservername </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer
done
== letsencrypt.org with SNI
subject=CN=letsencrypt.org
issuer=C=US, O=Let's Encrypt, CN=YE2
== letsencrypt.org without SNI
subject=C=US, ST=California, L=San Francisco, O=Netlify, Inc, CN=*.netlify.app
issuer=C=US, O=DigiCert Inc, CN=DigiCert Global G2 TLS RSA SHA256 2020 CA1
== www.google.com with SNI
subject=CN=www.google.com
issuer=C=US, O=Google Trust Services, CN=WE2
== www.google.com without SNI
subject=OU=No SNI provided - please fix your client., CN=invalid2.invalid
issuer=OU=No SNI provided - please fix your client., CN=invalid2.invalid
== github.com with SNI
subject=CN=github.com
issuer=C=GB, O=Sectigo Limited, CN=Sectigo Public Server Authentication CA DV E36
== github.com without SNI
subject=CN=github.com
issuer=C=GB, O=Sectigo Limited, CN=Sectigo Public Server Authentication CA DV E36
Host What the server did without SNI
and.guide (Cloudflare) Refused the handshake; no certificate
github.com Sent the same certificate as with SNI
letsencrypt.org Sent Netlify’s default *.netlify.app certificate, the platform serving the site
www.google.com Sent a self-signed placeholder that asks you to fix your client

Four servers, four behaviors. A missing SNI can look like a hostname mismatch, a self-signed certificate or a dead server, depending on who hosts the site.

The trap on a Mac is the built-in /usr/bin/openssl, which is LibreSSL 3.3.6. Live capture, 26 September 2026. The same connection without -servername:

/usr/bin/openssl s_client -connect and.guide:443 </dev/null
8408392064:error:1404B410:SSL routines:ST_CONNECT:sslv3 alert handshake failure:/AppleInternal/Library/BuildRoots/4~CVR0ugD7d_x9KkNiDhb6ZUdowAfhsgliSKtR7QU/Library/Caches/com.apple.xbs/TemporaryDirectory.kapob0/Sources/libressl/libressl-3.3/ssl/tls13_lib.c:129:SSL alert number 40
CONNECTED(00000006)
---
no peer certificate available
...

LibreSSL’s s_client did not send SNI, so it hit the same refusal. With -servername and.guide added, it returned the and.guide certificate. Passing -servername explicitly works with every version, so make it a habit.

One zone, two certificates

Live capture, 26 September 2026. The www host, and then the www name sent to the apex’s IP address:

openssl s_client -connect www.and.guide:443 -servername www.and.guide </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName
openssl s_client -connect 172.66.47.12:443 -servername www.and.guide </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -ext subjectAltName
subject=CN=and.guide
issuer=C=US, O=Let's Encrypt, CN=YE2
notBefore=Sep  6 14:25:26 2026 GMT
notAfter=Dec  5 14:25:25 2026 GMT
X509v3 Subject Alternative Name: 
    DNS:*.and.guide, DNS:and.guide
subject=CN=and.guide
issuer=C=US, O=Let's Encrypt, CN=YE2
X509v3 Subject Alternative Name: 
    DNS:*.and.guide, DNS:and.guide

The apex is our Cloudflare Pages custom domain and presents a Google Trust Services certificate for and.guide only. www.and.guide goes through Cloudflare’s proxy (it resolves to 104.21.70.196 and 172.67.138.236, not the Pages addresses) and presents a Let’s Encrypt certificate for and.guide and *.and.guide. Both certificates have the subject CN=and.guide, which is one more reason to read the SAN list and the issuer rather than the subject.

Sending www.and.guide as SNI to the apex’s IP returned the Let’s Encrypt certificate, so Cloudflare chose the certificate from the name, not the address. The practical consequence is for CAA: a CAA record that allowed only one of these two CAs would block the other certificate’s renewal. The CAA guide covers how to authorize more than one CA.

What a name mismatch looks like, and what you get instead on Cloudflare

badssl.com hosts deliberately misconfigured test subdomains, including wrong.host, expired and self-signed. Live capture, 26 September 2026. curl against a hostname its certificate does not cover, the certificate itself, and OpenSSL checking our apex certificate against www.and.guide:

curl -sSI https://wrong.host.badssl.com/ > /dev/null; echo "exit code: $?"
openssl s_client -connect wrong.host.badssl.com:443 -servername wrong.host.badssl.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -ext subjectAltName
openssl s_client -connect and.guide:443 -servername and.guide -verify_hostname www.and.guide </dev/null 2>&1 \
  | grep -E "verify error|Verification|Verify return code|depth=0"
curl: (60) SSL: no alternative certificate subject name matches target host name 'wrong.host.badssl.com'
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.
exit code: 60
subject=CN=*.badssl.com
X509v3 Subject Alternative Name: 
    DNS:*.badssl.com, DNS:badssl.com
depth=0 CN=and.guide
verify error:num=62:hostname mismatch
depth=0 CN=and.guide
Verification error: hostname mismatch
Verify return code: 62 (hostname mismatch)

*.badssl.com does not cover wrong.host.badssl.com because, under RFC 9525, a wildcard matches exactly one label; the wildcard subdomains guide covers what that means for certificate planning. curl reports the mismatch as exit code 60, its code for a certificate that failed verification. -verify_hostname lets you run the same check against any name before you point DNS at a server; here it confirms that our apex certificate cannot serve www.and.guide.

Cloudflare behaved differently for a name it had no certificate for. Live capture, 26 September 2026. A two-level name that no certificate in our zone covers, pinned to the apex’s IP with --resolve (which keeps the URL’s hostname for SNI and the Host header):

curl -sSI --resolve deep.www.and.guide:443:172.66.47.12 https://deep.www.and.guide/; echo "exit code: $?"
curl: (35) LibreSSL/3.3.6: error:1404B410:SSL routines:ST_CONNECT:sslv3 alert handshake failure
exit code: 35

With no certificate for the requested name, the edge refused the handshake, and curl reported exit code 35, a TLS handshake problem. On a Cloudflare-hosted name, check certificate coverage first when one hostname fails the handshake and its siblings don’t.

Days until expiry, and a check you can script

Live capture, 26 September 2026. The expiry date, the whole days left, and two -checkend tests (30 and 45 days, in seconds):

end=$(openssl s_client -connect and.guide:443 -servername and.guide </dev/null 2>/dev/null \
  | openssl x509 -noout -enddate | cut -d= -f2)
echo "$end"
echo $(( ( $(date -j -f "%b %e %H:%M:%S %Y %Z" "$end" +%s) - $(date +%s) ) / 86400 ))
openssl s_client -connect and.guide:443 -servername and.guide </dev/null 2>/dev/null \
  | openssl x509 -noout -checkend 2592000; echo "exit code: $?"
openssl s_client -connect and.guide:443 -servername and.guide </dev/null 2>/dev/null \
  | openssl x509 -noout -checkend 3888000; echo "exit code: $?"
Nov  6 06:34:36 2026 GMT
40
Certificate will not expire
exit code: 0
Certificate will expire
exit code: 1

The date -j -f form is macOS/BSD date syntax. For monitoring, -checkend is simpler than date arithmetic because the exit code is the answer: 0 means the certificate is still valid that many seconds from now, 1 means it will have expired. OpenSSL 3 can also print ISO 8601 dates with -dateopt iso_8601.

Live capture, 26 September 2026. Validity windows for the certificates in this guide, in ISO 8601:

for h in and.guide www.and.guide github.com letsencrypt.org www.google.com; do
  printf "%-16s " $h
  openssl s_client -connect $h:443 -servername $h </dev/null 2>/dev/null \
    | openssl x509 -noout -startdate -enddate -dateopt iso_8601 | tr '\n' ' '
  echo
done
and.guide        notBefore=2026-08-08 05:34:39Z notAfter=2026-11-06 06:34:36Z 
www.and.guide    notBefore=2026-09-06 14:25:26Z notAfter=2026-12-05 14:25:25Z 
github.com       notBefore=2026-09-01 00:00:00Z notAfter=2026-11-29 23:59:59Z 
letsencrypt.org  notBefore=2026-09-04 14:34:32Z notAfter=2026-12-03 14:34:31Z 
www.google.com   notBefore=2026-09-10 19:24:08Z notAfter=2026-12-03 19:24:07Z 

Every one is valid for about 90 days (84 for www.google.com). The CA/Browser Forum’s Baseline Requirements cap new certificates at 200 days from 15 March 2026, 100 days from 15 March 2027 and 47 days from 15 March 2029, so an expiry check that runs once a quarter is already too slow. Renewal has to be automated, and the check exists to catch the automation failing.

Mistakes these captures expose

  • Testing without SNI. Use -servername every time, especially with macOS’s LibreSSL openssl.
  • Trusting Verify return code: 0 (ok) alone. It appeared above for a connection with no certificate. Look for the certificate itself, or let curl do the full check.
  • Reading the CN. Two different and.guide certificates share CN=and.guide; only the SAN list shows that one covers *.and.guide and the other does not.
  • Treating -showcerts as the verified chain. It is what the server sent. The depth= lines show what OpenSSL verified.
  • Checking the CDN when you meant the origin. Point at the address you want with openssl s_client -connect <address>:443 -servername <name> or curl --resolve <name>:443:<address>; both keep the real hostname in SNI.
  • Assuming IPv6 was tested. and.guide publishes AAAA records, but curl on our workstation printed IPv6: (none), and curl -6 connected to the IPv4-mapped address ::ffff:172.66.44.244. Every capture here went over IPv4, so test IPv6 from a network that has it.

Before a hostname goes onto HSTS, and especially onto the preload list, run the SNI, SAN and expiry checks against every subdomain it will cover; the HSTS preload guide covers that commitment.