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.pemis the trust store this curl build uses. A different build or OS can trust a different set of roots.SSL connection usinglists 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-briefoutput for those.subjectAltName: host "and.guide" matchedis the hostname check, andSSL certificate verify ok.is the chain check. Both have to pass.ALPN: server accepted h2means 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
-servernameevery time, especially with macOS’s LibreSSLopenssl. - 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.guideand the other does not. - Treating
-showcertsas the verified chain. It is what the server sent. Thedepth=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>orcurl --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), andcurl -6connected 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.