Skip to content
Networking
Lab 6 of 7·35mIntermediate

Inspect TLS from the command line

Read a certificate chain, check expiry and SAN, watch a handshake, and find out why a browser trusts a site your curl does not.

You need

  • A Linux system with internet access
  • openssl and curl

Do first

"Certificate error" has about five distinct causes and the error message rarely names the right one. Two commands separate them.

1. Read what a server presents

echo | openssl s_client -connect example.com:443 \
  -servername example.com 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

Four facts: who it is for, who signed it, and the validity window. -servername sets SNI — the hostname sent _before_ the certificate is chosen. Omit it against a host serving many sites and you get whichever certificate is the default, which produces a mismatch error that has nothing to do with the real problem.

Verify

echo | openssl s_client -connect example.com:443 \ -servername example.com 2>/dev/null \ | openssl x509 -noout -subject | head -n 1 # subject=... CN=*.example.com (or similar)

2. Check expiry as a yes-or-no

echo | openssl s_client -connect example.com:443 \
  -servername example.com 2>/dev/null \
  | openssl x509 -noout -checkend 604800 \
  && echo "valid for at least 7 more days" \
  || echo "EXPIRES WITHIN 7 DAYS"

-checkend <seconds> exits zero or non-zero, which makes it usable in a monitoring check. This is the one to put in a cron job or a timer: certificate expiry is the most predictable outage in this profession and still one of the most common.

Verify

echo | openssl s_client -connect example.com:443 \ -servername example.com 2>/dev/null \ | openssl x509 -noout -checkend 86400 && echo OK # OK

3. The SAN is what is actually validated

echo | openssl s_client -connect example.com:443 \
  -servername example.com 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName

Clients have validated against the Subject Alternative Name extension, not the Common Name, for years. A certificate whose CN matches but whose SAN does not will be rejected — and reading only the CN is why the error looks wrong.

Note how wildcards work: *.example.com covers www.example.com but not example.com itself, and not a.b.example.com. One label, no apex. That asymmetry is behind a large share of certificate mismatches.

Verify

echo | openssl s_client -connect example.com:443 \ -servername example.com 2>/dev/null \ | openssl x509 -noout -ext subjectAltName | tail -n 1 # DNS:... entries listing every name the certificate covers

4. Read the chain, and find the incomplete-chain bug

echo | openssl s_client -connect example.com:443 \
  -servername example.com -showcerts 2>/dev/null \
  | grep -E "^ *[0-9]+ s:|^ *i:"

A server must send its own certificate and any intermediates. Only the root needs to be in the client's trust store. When a server sends the leaf alone, browsers often still work — they cache intermediates from previous sites — while curl and your application fail. That is the "works in my browser, fails in CI" report, and it is a server misconfiguration every time.

Verify the chain explicitly:

echo | openssl s_client -connect example.com:443 \
  -servername example.com -verify_return_error 2>&1 \
  | grep -E "Verify return code|verify error"

Verify return code: 0 (ok) is what you want. A non-zero code names the failure: 10 is expired, 18 is self-signed, 20 is "unable to get local issuer certificate" — the incomplete chain.

Verify

echo | openssl s_client -connect example.com:443 \ -servername example.com 2>&1 | grep -o "Verify return code: [0-9]*" # Verify return code: 0

5. Protocol and cipher

echo | openssl s_client -connect example.com:443 \
  -servername example.com 2>/dev/null \
  | grep -E "Protocol|Cipher"
openssl s_client -connect example.com:443 -servername example.com \
  -tls1_1 </dev/null 2>&1 | tail -n 3

The first shows what was negotiated — TLS 1.3 on anything modern. The second tries to force TLS 1.1 and should fail: it has been deprecated and disabled since 2021. If it _succeeds_ against one of your own servers, that is a finding.

Verify

echo | openssl s_client -connect example.com:443 \ -servername example.com 2>/dev/null | grep -o "TLSv1\.[23]" | head -n 1 # TLSv1.3 (or TLSv1.2)

6. Make a self-signed certificate and see the error from the inside

mkdir -p ~/labs/tls && cd ~/labs/tls
openssl req -x509 -newkey rsa:2048 -sha256 -days 3 -nodes \
  -keyout server.key -out server.crt \
  -subj "/CN=lab.local" \
  -addext "subjectAltName=DNS:lab.local,DNS:localhost"
openssl x509 -in server.crt -noout -subject -issuer -dates -ext subjectAltName

Subject and issuer are the same name — that is what "self-signed" means, and error code 18.

Serve it and connect:

openssl s_server -accept 8443 -cert server.crt -key server.key -quiet &
sleep 1
curl -sS https://localhost:8443 2>&1 | head -n 3
curl -sS --cacert server.crt https://localhost:8443 2>&1 | head -n 3
kill %1 2>/dev/null

The first fails: the signer is not in the trust store. The second succeeds, because you told curl to trust that specific certificate. That is the correct way to work with an internal CA — trust the CA explicitly. curl -k also "works", and it disables verification entirely, which means it is not a fix but a decision to stop checking.

Verify

curl -sS -o /dev/null -w "%{http_code}\n" --insecure https://localhost:8443 2>&1 | tail -n 1 # a status code or an error — either way, -k skipped verification

The five causes, and how to tell them apart

SymptomCauseCheck
certificate has expiredExpiry-checkend 0
hostname mismatchName not in SAN-ext subjectAltName
unable to get local issuerIncomplete chain (20)-showcerts
self-signed certificate (18)Internal CA not trusted--cacert
Works in browser, fails in curlIncomplete chain-showcerts, not the client

Clean up

cd ~ && rm -rf ~/labs/tls

Where this goes next

That is the vocabulary: addresses, prefixes, names, ports, routes, and TLS. The challenge puts a machine in front of you with two of them broken at once.