Software Engineering Wiki

Security and identity

TLS and certificates

Chain of trust, OpenSSL commands for inspecting and issuing certificates, trust stores and the diagnosis of a failed handshake.

Cheatsheet #

TaskCommand
Inspect a live certificateopenssl s_client -connect host:443 -servername host </dev/null | openssl x509 -noout -text
Expiry datesopenssl x509 -in cert.pem -noout -dates
Subject and SANsopenssl x509 -in cert.pem -noout -subject -ext subjectAltName
Verify a chainopenssl verify -CAfile ca.pem -untrusted intermediate.pem cert.pem
Does key match certdiff <(openssl pkey -in key.pem -pubout) <(openssl x509 -in cert.pem -noout -pubkey)
Read a CSRopenssl req -in req.csr -noout -text -verify
New key + CSRopenssl req -new -newkey rsa:2048 -nodes -keyout k.pem -out r.csr -config san.cnf
Self-signed for testingopenssl req -x509 -newkey ed25519 -nodes -days 30 -keyout k.pem -out c.pem -subj '/CN=test'
PEM to PKCS#12openssl pkcs12 -export -in cert.pem -inkey key.pem -certfile ca.pem -out bundle.p12
PKCS#12 to PEMopenssl pkcs12 -in bundle.p12 -nodes -out all.pem
Show the served chainopenssl s_client -connect host:443 -showcerts </dev/null
Test a protocol versionopenssl s_client -connect host:443 -tls1_2
Fingerprintopenssl x509 -in cert.pem -noout -fingerprint -sha256

The chain of trust #

A certificate binds a public key to a name and is signed by an issuer. Validation walks from the server’s leaf up through intermediates to a root the client already trusts, checking signature, validity dates, name match, and allowed usage at every step.

Three things fail independently: trust (is the root in this store), chain completeness (did the server send the intermediates), and name match (does a SAN cover the hostname requested). A browser may succeed where curl fails because browsers cache intermediates from earlier sites; servers must send the full chain.

openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName
SymptomCause
unable to get local issuer certificateServer sent an incomplete chain, or the root is not in this trust store
certificate has expiredLeaf or an intermediate is past notAfter — check every level
Hostname mismatchNo SAN covers the name; CN is ignored by modern clients
self signed certificate in certificate chainA private CA is in use and is not trusted here
tlsv1 alert unknown caThe server rejected the client certificate’s issuer (mTLS)
no shared cipherProtocol or cipher policy mismatch, or the wrong key type for the ciphersuites offered
Works in a browser, fails in curlMissing intermediate that the browser had cached

Reading certificates #

openssl x509 -in cert.pem -noout -text                      # everything
openssl x509 -in cert.pem -noout -subject -issuer -dates
openssl x509 -in cert.pem -noout -ext subjectAltName,keyUsage,extendedKeyUsage
openssl x509 -in cert.pem -noout -fingerprint -sha256
openssl x509 -in cert.pem -noout -checkend 604800           # expires within 7 days?
openssl crl2pkcs7 -nocrl -certfile chain.pem | openssl pkcs7 -print_certs -noout   # list a bundle

The fields that matter operationally: Not After, Subject Alternative Name, Key Usage / Extended Key Usage, and Basic Constraints (a CA certificate has CA:TRUE).

Keys and CSRs #

openssl genpkey -algorithm ed25519 -out key.pem                     # modern, small, fast
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out key.pem
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out key.pem
chmod 600 key.pem

openssl req -new -key key.pem -out req.csr -config san.cnf
openssl req -in req.csr -noout -text -verify                        # confirm before sending
# san.cnf — SANs are mandatory; CN alone is ignored
[req]
distinguished_name = dn
req_extensions     = v3_req
prompt             = no

[dn]
CN = api.example.com
O  = Example Pty Ltd
C  = AU

[v3_req]
keyUsage         = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName   = @alt

[alt]
DNS.1 = api.example.com
DNS.2 = api.internal.example.com
IP.1  = 10.0.0.5

Confirm a key and certificate belong together before deploying:

diff <(openssl pkey -in key.pem -pubout) <(openssl x509 -in cert.pem -noout -pubkey) && echo match

A private CA #

For internal services, issue from your own CA and distribute the root — not self-signed certificates per host, which cannot be validated or revoked as a set.

# Root (offline, long-lived)
openssl req -x509 -new -newkey rsa:4096 -nodes -days 3650 -keyout ca.key -out ca.pem \
  -subj '/CN=Example Internal Root CA/O=Example Pty Ltd/C=AU' \
  -addext 'basicConstraints=critical,CA:TRUE,pathlen:1' -addext 'keyUsage=critical,keyCertSign,cRLSign'

# Issue a leaf from a CSR
openssl x509 -req -in req.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 90 \
  -extfile san.cnf -extensions v3_req -out cert.pem

openssl verify -CAfile ca.pem cert.pem
cat cert.pem intermediate.pem > fullchain.pem      # leaf first, root omitted

Serve fullchain.pem: leaf, then intermediates, in order, root excluded. Order matters to strict clients, and including the root only wastes handshake bytes.

Trust stores #

Each runtime has its own store; installing a CA for the system does not necessarily reach the application.

PlatformLocationCommand
Debian/Ubuntu/usr/local/share/ca-certificates/*.crtupdate-ca-certificates
RHEL/Fedora/etc/pki/ca-trust/source/anchors/update-ca-trust extract
Alpine/usr/local/share/ca-certificates/update-ca-certificates
curl / OpenSSLSystem bundleSSL_CERT_FILE, --cacert
Python requestscertifi bundleREQUESTS_CA_BUNDLE
Node.jsBuilt-in listNODE_EXTRA_CA_CERTS
Javacacerts keystorekeytool -importcert -alias x -file ca.pem -cacerts
GoSystem storeSSL_CERT_FILE, or x509.SystemCertPool

In a container the store must be inside the image: apk add ca-certificates or COPY ca.pem /usr/local/share/ca-certificates/ plus the update command. x509: certificate signed by unknown authority from a scratch image usually means no CA bundle at all.

Formats #

FormatContentsTypical extension
PEMBase64 with -----BEGIN----- headers.pem, .crt, .key
DERBinary of the same structure.der, .cer
PKCS#12Encrypted bundle of key + certificates.p12, .pfx
PKCS#8Standard private key container.key, PEM-wrapped
JKSJava keystore (legacy).jks
openssl x509 -in cert.der -inform der -out cert.pem              # DER -> PEM
openssl x509 -in cert.pem -outform der -out cert.der             # PEM -> DER
openssl pkcs12 -export -in cert.pem -inkey key.pem -certfile ca.pem -out bundle.p12
openssl pkcs12 -in bundle.p12 -nodes -out all.pem                # extract everything
openssl rsa -in key.pem -out key.pkcs1.pem                       # PKCS#8 -> PKCS#1 for old software
keytool -importkeystore -srckeystore bundle.p12 -srcstoretype PKCS12 -destkeystore store.jks

Testing a server #

openssl s_client -connect host:443 -servername host </dev/null                 # handshake detail
openssl s_client -connect host:443 -tls1_2 </dev/null                          # force a version
openssl s_client -connect host:443 -cipher 'ECDHE-RSA-AES128-GCM-SHA256' </dev/null
openssl s_client -connect host:443 -cert client.pem -key client.key </dev/null # mTLS
openssl s_client -connect host:443 -status </dev/null | grep -A5 'OCSP'        # stapling
openssl s_client -connect host:25 -starttls smtp </dev/null                    # opportunistic TLS
openssl s_time -connect host:443 -new -time 10                                 # handshakes per second
nmap --script ssl-enum-ciphers -p 443 host                                     # full policy view

-servername sets SNI. Without it a virtual-hosted server returns its default certificate, which produces a name mismatch that does not exist in practice.

Configuration #

Support TLS 1.2 and 1.3, disable everything older, and prefer forward-secret suites. TLS 1.3 removes the cipher negotiation problem entirely — its five suites are all acceptable.

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305;
ssl_session_timeout 1d;
ssl_stapling on;
add_header Strict-Transport-Security "max-age=63072000" always;

HSTS is hard to undo: browsers honour max-age even after you remove the header. Start with a short value and raise it once certificates renew reliably.

Lifecycle #

Automate renewal and alert on expiry independently of the automation, because the failure mode is always “renewal silently stopped working three months ago”.

# Days remaining for a live endpoint
echo | openssl s_client -connect api.example.com:443 -servername api.example.com 2>/dev/null \
  | openssl x509 -noout -enddate | cut -d= -f2 \
  | xargs -I{} sh -c 'echo $(( ( $(date -d "{}" +%s) - $(date +%s) ) / 86400 )) days'

Rotate the key at renewal rather than reusing it, keep the private key off shared storage and out of version control, and treat any key that has been emailed or pasted as compromised.

Oneliners #

# Expiry for a list of hosts
while read -r h; do d=$(echo | openssl s_client -connect "$h:443" -servername "$h" 2>/dev/null | openssl x509 -noout -enddate | cut -d= -f2); printf '%-35s %s\n' "$h" "$d"; done < hosts.txt

# Fail if a certificate expires within 30 days
openssl x509 -in cert.pem -noout -checkend 2592000 || echo 'renew now'

# Which SANs does a live certificate carry
echo | openssl s_client -connect host:443 -servername host 2>/dev/null | openssl x509 -noout -ext subjectAltName

# Chain as served, subject and issuer only
echo | openssl s_client -connect host:443 -showcerts 2>/dev/null | awk '/BEGIN CERT/,/END CERT/' | openssl crl2pkcs7 -nocrl -certfile /dev/stdin | openssl pkcs7 -print_certs -noout

# Verify a chain file offline
openssl verify -CAfile root.pem -untrusted intermediate.pem cert.pem

# Certificate from a Kubernetes secret
kubectl get secret tls-api -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -subject -dates

# Compare deployed certificate with the file on disk
diff <(openssl x509 -in cert.pem -noout -fingerprint -sha256) <(echo | openssl s_client -connect host:443 -servername host 2>/dev/null | openssl x509 -noout -fingerprint -sha256)

# Protocol versions a server accepts
for p in tls1 tls1_1 tls1_2 tls1_3; do printf '%-8s ' "$p"; echo | openssl s_client -connect host:443 -"$p" >/dev/null 2>&1 && echo yes || echo no; done

# Generate a throwaway certificate for local testing
openssl req -x509 -newkey ed25519 -nodes -days 30 -keyout /tmp/k.pem -out /tmp/c.pem -subj '/CN=localhost' -addext 'subjectAltName=DNS:localhost,IP:127.0.0.1'

# Decode a certificate pasted as base64
base64 -d <<< "$B64" | openssl x509 -noout -text

# Serial numbers of everything in a bundle
openssl crl2pkcs7 -nocrl -certfile bundle.pem | openssl pkcs7 -print_certs | grep -E 'subject|serial'

Further reading #

Last updated 15 September 2026 · Edit this page