Available for day contractsFrom 21st September I have availability for day and half day contracts. Please contact for more information.

Contact →
mikepreston.org

TLS, OpenSSL & Certificates

The everyday OpenSSL toolkit, modernised: keys and CSRs with SANs, self-signed and CA-signed certs, inspection and verification, s_client debugging, format conversion, expiry checks, and ACME troubleshooting.

TLS, OpenSSL & Certificates

The commands you reach for when a certificate breaks at 2 a.m., brought up to OpenSSL 3.x best practice.


Overview

This is the everyday OpenSSL toolkit: generate keys and CSRs, issue self-signed and CA-signed certificates, inspect and verify whatever you've been handed, debug a live endpoint with s_client, convert between formats, and untangle ACME failures. It is a deliberately modernised take on the long-serving "Most Common OpenSSL Commands" reference that did the rounds for years — the command groupings are the same well-worn set (key + CSR, self-signed, CSR for an existing key, strip a passphrase, inspect, verify the trio matches, connect, convert), but every example here assumes OpenSSL 3.x and current practice: SANs always (CN-only certificates are dead — browsers ignore the CN), ed25519 and ECDSA shown alongside RSA 4096, -addext instead of hand-edited config where possible, and no MD5 or weak ciphers except where one legacy trick is explicitly demonstrated and labelled.

For the Kubernetes ACME story — automated issuance, renewal, HTTP-01/DNS-01 solvers as Custom Resources — see the Cert-Manager sheet. This sheet is the general-purpose, tool-agnostic counterpart: the raw openssl you use everywhere else, plus the diagnostics that tell you why cert-manager (or certbot, or your load balancer) is unhappy.

openssl req -newsubmitsignssignssignsserved with chainclient verifies uptoPrivate keyCSRpublic key + subject+ SANsCertificateAuthorityLeaf certificateRoot CAIntermediate CATLS serveropenssl req -newsubmitsignssignssignsserved with chainclient verifies uptoPrivate keyCSRpublic key + subject+ SANsCertificateAuthorityLeaf certificateRoot CAIntermediate CATLS server

The private key never leaves you. The CSR carries your public key plus the names you're asserting; the CA signs it into a leaf certificate. Clients trust the leaf only if they can build a chain from it, through any intermediates, up to a root they already trust — so the server must serve the leaf and the intermediates (the "fullchain"), never the leaf alone.


Key Concepts and Terminology

A quick vocabulary, because half of all certificate confusion is terminology.

Term What it is
Private key The secret. Signs/decrypts. Guard it; everything else is derived from or bound to it.
Public key Derived from the private key. Embedded in the CSR and the certificate.
CSR Certificate Signing Request: public key + subject + requested SANs, self-signed by your key as proof of possession. You send this to a CA.
Certificate A CA's signature over your public key, subject, SANs, validity dates, and key usages. An X.509 document.
SAN Subject Alternative Name: the list of DNS names / IPs the cert is actually valid for. Browsers use only this, not the CN.
Chain / fullchain Leaf + intermediate(s), in order, so a client can build a path to a trusted root.
CA bundle The set of root + intermediate certs a verifier trusts (e.g. /etc/ssl/certs/ca-certificates.crt).
PEM Base64 text, -----BEGIN …----- armour. The default everywhere on Linux.
DER The same data, binary. Common on Windows/Java and for .cer files.
PKCS#12 / PFX A single password-protected .p12/.pfx blob holding key + cert + chain. Windows, Java keystores, some appliances.

PEM and DER are encodings of the same X.509 structure; PKCS#12 is a container that bundles a key with its certs.


Generating Keys

openssl genpkey is the modern, algorithm-agnostic generator and the one to standardise on. genrsa and ecparam/gendsa still work and you'll see them everywhere, but genpkey is the consistent front door.

# ed25519 — fast, tiny, excellent default for anything that supports it
openssl genpkey -algorithm ed25519 -out key.pem

# ECDSA P-256 (prime256v1) — the safe, universally accepted modern default
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out key.pem
# P-384 if you want a heavier curve:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 -out key.pem

# RSA 4096 — when a counterparty insists on RSA (3072 is the floor; 2048 only if forced)
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out key.pem

# Encrypt the key at rest with AES-256 (prompts for a passphrase)
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \
  -aes-256-cbc -out key.pem

A note on algorithm choice: ed25519 for internal services and anything you control end to end; ECDSA P-256 as the no-argument public default (every client understands it, and it's far lighter than RSA); RSA 4096 only where a legacy peer, an HSM, or a CA profile demands RSA. Public CAs and many appliances are still RSA/ECDSA-only — ed25519 leaf certs from public ACME are not yet broadly available, so reach for P-256 there.

The older forms, for when you meet them:

openssl genrsa -out key.pem 4096                      # legacy RSA generator
openssl ecparam -name prime256v1 -genkey -out key.pem # legacy EC generator

Inspect any private key — the modern pkey command handles every algorithm:

openssl pkey -in key.pem -text -noout      # full detail
openssl pkey -in key.pem -pubout           # extract just the public key

CSRs with SANs

The single most important modernisation: put your names in subjectAltName, not the CN. Browsers and most modern clients ignore the Common Name entirely. A CSR without a SAN is a CSR that produces a useless certificate.

One-shot: new key + CSR together

# Generate a fresh key and a CSR in one go.
# -nodes = "no DES" = don't encrypt the key (you usually don't want a passphrase
#          on a server key, since the server needs to start unattended).
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
  -keyout key.pem -out request.csr \
  -subj "/CN=example.com" \
  -addext "subjectAltName=DNS:example.com,DNS:www.example.com"

# RSA 4096 variant:
openssl req -new -newkey rsa:4096 -nodes \
  -keyout key.pem -out request.csr \
  -subj "/CN=example.com" \
  -addext "subjectAltName=DNS:example.com,DNS:www.example.com"

-subj is non-interactive (no prompts); drop it to be prompted for the subject fields. The CN is now purely cosmetic — set it to your primary hostname out of habit, but the SAN is what's enforced.

CSR for an existing key

The classic "I already have the key, I just need a fresh CSR" — common at renewal if you're pinning the key.

openssl req -new -key key.pem -out request.csr \
  -subj "/CN=example.com" \
  -addext "subjectAltName=DNS:example.com,DNS:www.example.com"

Many SANs: use a config file

-addext gets unwieldy past a couple of names. For anything substantial, drive it from a config file — also the only clean way to pin key usages and add IP SANs.

# csr.cnf
[ req ]
distinguished_name = dn
req_extensions     = v3_req
prompt             = no

[ dn ]
CN = example.com
O  = Example Ltd
C  = GB

[ v3_req ]
basicConstraints = CA:FALSE
keyUsage         = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName   = @alt_names

[ alt_names ]
DNS.1 = example.com
DNS.2 = www.example.com
DNS.3 = api.example.com
IP.1  = 192.0.2.10
openssl req -new -key key.pem -out request.csr -config csr.cnf

Inspect a CSR before you send it

Always check a CSR carries the SANs you think it does — a CSR with the wrong (or no) SAN is the commonest reason an issued cert is useless.

openssl req -in request.csr -noout -text -verify
# -verify checks the CSR's self-signature (proof the key signed it)
# scan the output for "Subject Alternative Name"

Self-Signed Certificates and a Tiny Internal CA

Self-signed certs are fine for internal services, test rigs, and bootstrap — anything where you control both ends and can distribute trust yourself. Public-facing certificates come from ACME / Let's Encrypt, never from this section. See Cert-Manager for the automated Kubernetes path.

A single self-signed cert (with SANs)

# ECDSA key + self-signed cert, valid 2 years, SAN-bearing
openssl req -x509 -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
  -keyout key.pem -out cert.pem -days 825 \
  -subj "/CN=app.internal" \
  -addext "subjectAltName=DNS:app.internal,DNS:localhost,IP:127.0.0.1"

825 days is the historical browser cap for manually-trusted certs; for purely internal use you can go longer, but short-lived and re-issued is healthier. Confirm the SAN landed:

openssl x509 -in cert.pem -noout -ext subjectAltName

Stand up a small internal CA

When you want one trust anchor you distribute once, then sign many leaf certs against it — far better than scattering self-signed certs that each need importing.

# 1. CA key + self-signed CA cert (10 years). The CA cert is what you distribute
#    to clients' trust stores.
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out ca.key
openssl req -x509 -new -key ca.key -days 3650 -out ca.crt \
  -subj "/CN=Example Internal CA" \
  -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign,cRLSign"

# 2. Leaf key + CSR (with SANs, as always)
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out leaf.key
openssl req -new -key leaf.key -out leaf.csr \
  -subj "/CN=app.internal" \
  -addext "subjectAltName=DNS:app.internal"

# 3. Sign the leaf with the CA. Extensions for the leaf go in an ext file —
#    crucially the SAN, which is NOT copied from the CSR by default.
cat > leaf.ext <<'EOF'
subjectAltName = DNS:app.internal
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
EOF

openssl x509 -req -in leaf.csr \
  -CA ca.crt -CAkey ca.key -CAcreateserial \
  -days 365 -extfile leaf.ext -out leaf.crt

# 4. Verify the leaf chains to the CA
openssl verify -CAfile ca.crt leaf.crt   # -> leaf.crt: OK

The gotcha that bites everyone: openssl x509 -req does not carry SANs over from the CSR. You must restate them in -extfile, or the signed cert ships with no SAN and is rejected by every browser.


Passphrases: Removing and Adding

A passphrase on a server key means a human (or a stored secret) must unlock it on every restart — usually undesirable for unattended services, which is why server keys are commonly stored unencrypted on disk and protected by file permissions and a secrets manager instead.

# Remove a passphrase (prompts for the current one)
openssl pkey -in encrypted.key -out plain.key

# Add / change a passphrase (AES-256), prompts for the new one
openssl pkey -in plain.key -aes-256-cbc -out encrypted.key

# Legacy RSA-specific forms you'll still see:
openssl rsa -in encrypted.key -out plain.key            # strip
openssl rsa -in plain.key -aes256 -out encrypted.key    # add

Security note: stripping the passphrase writes the raw key to disk. Do it with a tight umask, in a directory only you can read, and never commit the result. For production secrets, prefer an unencrypted key delivered into the runtime by a secrets manager (see Vault / SOPS) over a passphrase-protected file plus a stored passphrase — you've just moved the problem, not solved it.


Inspecting Certificates and Keys

The bread and butter: "what is this thing, and is it still good?"

# Everything about a certificate
openssl x509 -in cert.pem -noout -text

# Just the validity window (the question you ask most)
openssl x509 -in cert.pem -noout -dates
openssl x509 -in cert.pem -noout -enddate        # expiry only

# Who it's for and who signed it
openssl x509 -in cert.pem -noout -subject -issuer

# The SANs — what it's actually valid for
openssl x509 -in cert.pem -noout -ext subjectAltName

# Fingerprint (for pinning / comparison) and serial
openssl x509 -in cert.pem -noout -fingerprint -sha256
openssl x509 -in cert.pem -noout -serial

# Will it expire within the next 30 days? Sets exit code, no output —
# ideal for cron/monitoring.
openssl x509 -in cert.pem -checkend $((30*86400)) && echo "OK" || echo "EXPIRING"

Inspect a key, and read what's inside a PKCS#12 bundle:

openssl pkey -in key.pem -text -noout            # any key type

# What's in this .p12/.pfx? (prompts for the import password)
openssl pkcs12 -in bundle.p12 -info -nokeys      # certs only, key stays sealed

Verifying Key ↔ CSR ↔ Cert Match

The classic "do these three actually belong together?" check, before you deploy a key/cert pair and watch the handshake fail.

Modern way (works for every algorithm)

Compare the public keys. This is algorithm-agnostic — it works for ed25519, ECDSA, and RSA alike, unlike the old modulus trick.

# Hash each public key; all three must match for the trio to belong together.
openssl pkey -in key.pem -pubout 2>/dev/null         | openssl sha256
openssl req  -in request.csr -noout -pubkey 2>/dev/null | openssl sha256
openssl x509 -in cert.pem -noout -pubkey 2>/dev/null  | openssl sha256

Three identical SHA-256 sums means the key generated the CSR and the cert was issued for that key. Any mismatch and you've crossed your wires somewhere.

Legacy modulus trick (RSA only — labelled deprecated)

You'll see this all over older runbooks. It only works for RSA keys (EC/ed25519 have no "modulus"), and it uses MD5 purely as a cheap comparison hash — fine here, but don't carry MD5 into anything that matters:

# RSA ONLY. Prefer the -pubout/sha256 method above for new work.
openssl rsa -in key.pem  -noout -modulus | openssl md5
openssl req -in request.csr -noout -modulus | openssl md5
openssl x509 -in cert.pem -noout -modulus | openssl md5

Debugging Live Endpoints with s_client

s_client is the highest-value command in this sheet for anyone doing SRE work — it shows you exactly what a server presents on the wire.

# The essential connect. -servername sends SNI — without it, a multi-tenant
# host hands you the WRONG (default) certificate and you'll chase a ghost.
openssl s_client -connect example.com:443 -servername example.com </dev/null

# Show the full chain the server sends — the key to diagnosing missing intermediates
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null

</dev/null closes stdin so the command returns instead of hanging waiting for input.

One-liners you'll actually use

# What are this endpoint's dates? (pipe the served leaf into x509)
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -dates

# What SANs does it actually serve?
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName

# Did the chain verify, and what protocol/cipher did we negotiate?
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>&1 \
  | grep -E "Verify return code|Protocol|Cipher"
# "Verify return code: 0 (ok)" is what you want.

Pinning protocol, supplying a CA, STARTTLS

# Force a protocol to test support (or prove a server still allows something it shouldn't)
openssl s_client -connect example.com:443 -servername example.com -tls1_3 </dev/null
openssl s_client -connect example.com:443 -servername example.com -tls1_2 </dev/null

# Verify against a specific CA bundle (e.g. an internal CA)
openssl s_client -connect app.internal:443 -servername app.internal \
  -CAfile ca.crt </dev/null

# Protocols that upgrade to TLS mid-session
openssl s_client -starttls smtp     -connect mail.example.com:587 </dev/null
openssl s_client -starttls imap     -connect mail.example.com:143 </dev/null
openssl s_client -starttls postgres -connect db.example.com:5432  </dev/null

Diagnosing a missing intermediate

If Verify return code is 21 (unable to verify the first certificate) or 20 (unable to get local issuer certificate) but the cert is otherwise fine, the server is almost certainly serving the leaf only and omitting the intermediate. Count the certificates under -showcerts: a correctly-configured public endpoint sends at least two (leaf + intermediate). One certificate is the tell.

openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null \
  | grep -c "BEGIN CERTIFICATE"   # 1 = missing intermediate; >=2 = chain present

For protocol and cipher configuration (what your server should be offering in the first place), follow the Mozilla "modern" profile — it gives you the current TLS 1.3-only cipher suite and protocol settings to drop straight into Nginx or HAProxy. Don't hand-pick cipher strings; copy the profile.


Expiry Checks at Scale

# Expiry for a remote host, one line
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -enddate

# A local cert file
openssl x509 -in cert.pem -noout -enddate

# Exit-code check for monitoring/cron (no output; status 0 = still good)
openssl x509 -in cert.pem -checkend $((14*86400))

# Verify a leaf against its chain bundle
openssl verify -CAfile fullchain-or-cabundle.pem cert.pem

Don't roll your own expiry monitor in shell for production. The exit-code form above is fine for a quick cron sanity check, but for fleet-wide visibility use the Prometheus blackbox exporter (its probe_ssl_earliest_cert_expiry metric) or, in Kubernetes, let Cert-Manager own renewal so expiry stops being a thing you watch at all.


Format Conversion

From → To Command
PEM → DER openssl x509 -in cert.pem -outform DER -out cert.der
DER → PEM openssl x509 -inform DER -in cert.der -out cert.pem
PEM key → DER openssl pkey -in key.pem -outform DER -out key.der
PEM → PKCS#12 openssl pkcs12 -export -inkey key.pem -in cert.pem -certfile chain.pem -out bundle.p12
PKCS#12 → PEM openssl pkcs12 -in bundle.p12 -nodes -out all.pem
Build a fullchain cat leaf.pem intermediate.pem > fullchain.pem
# PEM bundle -> PKCS#12 for Windows / Java keystores (prompts for export password)
openssl pkcs12 -export \
  -inkey key.pem -in leaf.pem -certfile chain.pem \
  -name "example.com" -out bundle.p12

# PKCS#12 -> everything as PEM (key unencrypted; mind the file permissions)
openssl pkcs12 -in bundle.p12 -nodes -out all.pem
# ...or split key and certs out:
openssl pkcs12 -in bundle.p12 -nocerts -nodes -out key.pem    # key only
openssl pkcs12 -in bundle.p12 -clcerts -nokeys -out leaf.pem  # leaf only
openssl pkcs12 -in bundle.p12 -cacerts -nokeys -out chain.pem # chain only

Chain order matters. A fullchain.pem must be leaf first, then intermediate(s), ascending toward (but not including) the root. Some servers tolerate a wrong order; many clients don't. When in doubt, build it explicitly with cat in that order rather than trusting whatever a tool emitted.


ACME / Let's Encrypt Debugging

ACME automates public certificate issuance by proving you control a domain. The three challenge types, at a glance:

Challenge Proves control via Use when
HTTP-01 A token served at http://<domain>/.well-known/acme-challenge/<token> on port 80 Single host, port 80 reachable from the internet. No wildcards.
DNS-01 A _acme-challenge.<domain> TXT record Wildcards, private hosts, or when port 80 is closed. Needs DNS API access.
TLS-ALPN-01 A special cert on port 443 via the acme-tls/1 ALPN protocol Only port 443 is open; handled inside the TLS-terminating proxy.

This sheet is tool-agnostic — certbot, acme.sh, lego, and cert-manager all speak the same protocol; pick whichever fits your platform. For the Kubernetes flow specifically (issuers, solvers, automatic renewal as CRDs), go straight to Cert-Manager. What follows is how to diagnose ACME failures with the openssl you already have.

Common failures and how to spot them

Symptom Likely cause Diagnose / fix
HTTP-01 never validates Port 80 blocked, or a redirect/WAF in front of /.well-known/ curl -v http://<domain>/.well-known/acme-challenge/test from outside your network
DNS-01 times out _acme-challenge TXT not propagated, or wrong zone dig +short TXT _acme-challenge.<domain> @1.1.1.1 — see DNS Troubleshooting
"too many certificates already issued" Hit a Let's Encrypt rate limit Use the staging endpoint while testing; deduplicate certs
Browser trusts staging but not prod (or vice versa) Wrong ACME directory URL Staging certs are untrusted by design — confirm you finalised against production
Cert valid but clients still error Wrong / incomplete chain served openssl s_client … -showcerts and count certs (see s_client section)
"signature invalid" / nonce errors Clock skew on the requesting host timedatectl / check NTP — ACME is time-sensitive

Confirm what a server actually serves

Most "the cert is fine but it doesn't work" tickets are resolved by looking at what's genuinely on the wire — issuer, dates, and chain length — rather than what you think you deployed:

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

If the issuer says (STAGING) Let's Encrypt or Fake LE, you've deployed a staging cert to production. If there's only one certificate in -showcerts, your ACME client fetched the cert but your server config is pointing at the leaf-only file instead of the fullchain.


Quick Reference

The table people scan. All commands assume OpenSSL 3.x.

Task Command
Generate ed25519 key openssl genpkey -algorithm ed25519 -out key.pem
Generate ECDSA P-256 key openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out key.pem
Generate RSA 4096 key openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out key.pem
New key + CSR with SANs openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout key.pem -out req.csr -subj "/CN=example.com" -addext "subjectAltName=DNS:example.com"
CSR for existing key openssl req -new -key key.pem -out req.csr -subj "/CN=example.com" -addext "subjectAltName=DNS:example.com"
CSR from config file openssl req -new -key key.pem -out req.csr -config csr.cnf
Self-signed cert (SANs) openssl req -x509 -new -key key.pem -days 825 -out cert.pem -subj "/CN=app.internal" -addext "subjectAltName=DNS:app.internal"
Sign a leaf with a CA openssl x509 -req -in leaf.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 365 -extfile leaf.ext -out leaf.crt
Remove passphrase openssl pkey -in enc.key -out plain.key
Add passphrase openssl pkey -in plain.key -aes-256-cbc -out enc.key
Inspect cert (full) openssl x509 -in cert.pem -noout -text
Cert expiry only openssl x509 -in cert.pem -noout -enddate
Cert subject + issuer openssl x509 -in cert.pem -noout -subject -issuer
Cert SANs openssl x509 -in cert.pem -noout -ext subjectAltName
Cert fingerprint openssl x509 -in cert.pem -noout -fingerprint -sha256
Expiring within 30 days? openssl x509 -in cert.pem -checkend $((30*86400))
Inspect CSR openssl req -in req.csr -noout -text -verify
Inspect key openssl pkey -in key.pem -text -noout
Inspect PKCS#12 openssl pkcs12 -in bundle.p12 -info -nokeys
Match key/CSR/cert (any algo) openssl pkey -in key.pem -pubout | openssl sha256 (compare vs req -pubkey, x509 -pubkey)
Match (RSA legacy) openssl rsa -in key.pem -noout -modulus | openssl md5
Connect + show chain openssl s_client -connect host:443 -servername host -showcerts </dev/null
Remote expiry echo | openssl s_client -connect host:443 -servername host 2>/dev/null | openssl x509 -noout -enddate
Verify chain openssl verify -CAfile chain.pem cert.pem
PEM → DER openssl x509 -in cert.pem -outform DER -out cert.der
DER → PEM openssl x509 -inform DER -in cert.der -out cert.pem
PEM → PKCS#12 openssl pkcs12 -export -inkey key.pem -in cert.pem -certfile chain.pem -out bundle.p12
PKCS#12 → PEM openssl pkcs12 -in bundle.p12 -nodes -out all.pem
Build fullchain cat leaf.pem intermediate.pem > fullchain.pem

Common Issues and Solutions

"unable to get local issuer certificate" (verify code 20)

The verifier can't build a path to a trusted root.

# Is the server even sending the intermediate?
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null \
  | grep -c "BEGIN CERTIFICATE"   # 1 means leaf-only — that's the bug

Fix: serve the fullchain (leaf + intermediate), not the leaf alone. If you're the client and the root is missing locally, point at the right bundle with -CAfile (or install the CA into the system trust store).

Missing intermediate / incomplete chain (verify code 21)

21 (unable to verify the first certificate) is the same class of problem from the other side: the chain handed over is incomplete. Some browsers paper over it via cached intermediates or AIA fetching, so it "works on my machine" while curl, Java, and Go clients all fail. Rebuild the served file as a proper fullchain and reload the server.

SNI mismatch — wrong certificate served

# Without -servername you get the host's DEFAULT vhost cert, not yours.
# Always pass SNI; if adding it changes the cert you see, SNI is your issue.
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -ext subjectAltName

Fix: ensure the server has a vhost/SNI mapping for that exact hostname, and that the cert's SANs include it.

Certificate expired or expiring

openssl x509 -in cert.pem -noout -dates
openssl x509 -in cert.pem -checkend 0 && echo "still valid" || echo "EXPIRED"

Fix: renew (ACME should be automatic — if it isn't, that's the real bug; see Cert-Manager or your ACME client's logs). After renewing, reload the server — a renewed file on disk does nothing until the process re-reads it.

PKCS#12 error on OpenSSL 3.x ("legacy" provider)

Reading an old .pfx produced by ancient tooling (RC2/3DES, weak MACs) fails under OpenSSL 3.x because those algorithms moved to the legacy provider, which isn't loaded by default:

Error outputting keys and certificates
... unsupported:... Algorithm (RC2-40-CBC : 0)

Fix: load the legacy provider for that one command.

openssl pkcs12 -in old.pfx -nodes -out all.pem -legacy
# Re-export cleanly so you never hit this again (modern AES-256 PBES2):
openssl pkcs12 -export -inkey all.pem -in all.pem -out new.p12

Wrong key/cert pair ("key values mismatch")

The server refuses to start, or the handshake dies, because the certificate wasn't issued for the private key you've paired it with.

# All three (or at least key + cert) must produce the same hash:
openssl pkey -in key.pem  -pubout 2>/dev/null | openssl sha256
openssl x509 -in cert.pem -noout -pubkey 2>/dev/null | openssl sha256

Fix: find the key that actually matches the cert (or re-issue the cert for the key you have). This is overwhelmingly a copy-paste/deploy-order mistake — the right key is usually sitting next to the wrong one.

s_client appears to hang

It's waiting on stdin for HTTP input, not failing. Append </dev/null (or pipe echo | in) so it connects, prints, and exits.


Related Topics

The following sheets pair naturally with this one:

  1. Cert-Manager — the Kubernetes-native way to automate everything in the ACME section: issuers, solvers, and renewal as Custom Resources.

  2. DNS Troubleshooting — DNS-01 challenges live or die on TXT-record propagation; this is where you debug _acme-challenge records.

  3. Nginx — terminating TLS, wiring up ssl_certificate/ssl_certificate_key, and applying the Mozilla modern profile in practice.

  4. HAProxy — TLS termination and passthrough at the load balancer, where chain order and SNI matter most.

  5. Vault — issuing short-lived certificates from a PKI secrets engine, and storing keys properly instead of leaving passphrase-stripped files on disk.

  6. Security Best Practices — key handling, rotation, and the trust-model thinking that sits behind all of the above.