TLS certificates with OpenSSL

formshellpath

Almost every certificate problem is one of five questions: what is this server sending me, when does it expire, does this key match this certificate, how do I make one for local development, and what format does this other tool want. openssl answers all five, but its subcommands are old and their flags are not obvious.

Everything below is verified on OpenSSL 4.0.3, with Ubuntu’s 3.0.x builds cross-checked, and the flags that changed in the 3.x series are called out. OpenSSL 3.0 prints names with spaces around = (subject=CN = example.com); 3.5 and later print them packed, as shown here.

Reading

What certificate does this server present?

openssl s_client -servername example.com -connect example.com:443 </dev/null \
  | openssl x509 -noout -subject -issuer -dates
subject=CN=example.com
issuer=C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
notBefore=Sep 26 22:49:11 2026 GMT
notAfter=Dec 25 22:56:35 2026 GMT

-servername sends the SNI name. Without it, a host serving several sites hands back whichever certificate it considers the default, which is how you end up debugging the wrong certificate for an hour. The </dev/null matters too: s_client opens an interactive session and waits on stdin otherwise.

Add -showcerts to get every certificate in the chain, not just the leaf. To check whether the chain validates against your trust store, and what got negotiated, read the summary block s_client prints after the handshake:

openssl s_client -servername example.com -connect example.com:443 </dev/null 2>/dev/null \
  | grep -E 'Verify return code|^New,'
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Verify return code: 0 (ok)

Read the New, line with care: it names the cipher and the protocol version that cipher was defined for, not the version negotiated. A TLS 1.2 session on AES256-SHA prints New, SSLv3, Cipher is AES256-SHA; the Protocol line further down says TLSv1.2.

Is it about to expire?

openssl x509 -in cert.pem -noout -checkend 86400

-checkend N asks “will this certificate be expired N seconds from now”. It prints a sentence, but the useful part is the exit code: 0 if still valid, 1 if it will have expired. That makes it a one-line monitoring check, remote server included:

openssl s_client -servername example.com -connect example.com:443 </dev/null 2>/dev/null \
  | openssl x509 -noout -checkend $((30*86400)) || echo "expires within 30 days"

Beware if your script runs under set -e: a certificate near expiry makes -checkend return 1 and kills the script. Put it in an if or after ||.

Which TLS versions does a server accept?

for v in tls1_1 tls1_2 tls1_3; do
  if openssl s_client -$v -servername example.com -connect example.com:443 </dev/null >/dev/null 2>&1
  then echo "$v: accepted"; else echo "$v: refused"; fi
done
tls1_1: refused
tls1_2: accepted
tls1_3: accepted

s_client exits 1 when the handshake never happens, so the exit code is the whole answer.

One catch: a refusal can come from your own client. OpenSSL disables TLS 1.0 and 1.1 at security level 1 and above, and the default level is 2, so any OpenSSL 3.x or 4.x refuses them before a packet leaves the machine. The level is not always in a file you can grep. Ubuntu compiles it in, and openssl version -a reports it among the compiler options as -DOPENSSL_TLS_SECURITY_LEVEL=2; Ubuntu 22.04 also pins it in /etc/ssl/openssl.cnf, as CipherString = DEFAULT:@SECLEVEL=2 under [system_default_sect], a line 24.04 dropped. Debian 13 does neither and relies on the upstream default. To tell a client refusal from a server refusal, lower the level for one call:

openssl s_client -tls1_1 -cipher 'DEFAULT@SECLEVEL=0' -connect example.com:443 </dev/null

If the handshake succeeds that way, your client was the one saying no.

Does this key go with this certificate?

Compare the public keys, not the files:

openssl x509 -in cert.pem -noout -pubkey | openssl sha256
openssl pkey -in key.pem -pubout      | openssl sha256

Identical digests, they belong together. This is the modern form; the widespread recipe comparing -modulus output only works for RSA, while pkey -pubout handles EC keys the same way.

To identify a certificate against a fingerprint someone gave you:

openssl x509 -in cert.pem -noout -fingerprint -sha256

Creating

A self-signed certificate that browsers accept

openssl req -x509 -newkey rsa:2048 -noenc -keyout key.pem -out cert.pem -days 365 \
  -subj "/CN=example.local" \
  -addext "subjectAltName=DNS:example.local,DNS:*.example.local,IP:127.0.0.1"

Two things people miss here.

The SAN is not optional. Browsers stopped reading the Common Name for host matching years ago. A certificate with only -subj "/CN=example.local" and no subjectAltName is rejected, and the error message rarely says why. Check that the extension landed:

openssl x509 -in cert.pem -noout -ext subjectAltName
X509v3 Subject Alternative Name:
    DNS:example.local, DNS:*.example.local, IP Address:127.0.0.1

-nodes is now -noenc. Both mean “do not encrypt the private key”, so no passphrase prompt and no passphrase to supply at startup. -nodes still works but OpenSSL 3.x marks it deprecated in openssl req -help; new scripts should use -noenc.

A certificate signing request

openssl req -new -newkey rsa:2048 -noenc -keyout svc.key -out svc.csr \
  -subj "/CN=svc.internal" -addext "subjectAltName=DNS:svc.internal"

openssl req -in svc.csr -noout -verify -subject
Certificate request self-signature verify OK
subject=CN=svc.internal

Same command as above minus -x509: with -x509 you get a finished certificate, without it you get a request for someone else to sign.

Signing that request with your own CA

Useful for a development or internal PKI: one root you install once in the trust stores, then any number of certificates signed by it.

# the root, once
openssl req -x509 -newkey rsa:2048 -noenc -keyout ca.key -out ca.crt -days 3650 \
  -subj "/CN=Dev Root CA"

# sign a request with it
openssl x509 -req -in svc.csr -CA ca.crt -CAkey ca.key -out svc.crt -days 365 \
  -copy_extensions copy

openssl verify -CAfile ca.crt svc.crt
svc.crt: OK

-copy_extensions copy is the flag that matters. Without it, x509 -req drops the extensions carried by the CSR, including the SAN. The result verifies fine and is still rejected by every browser, and the only hint is that openssl x509 -noout -ext subjectAltName answers No extensions in certificate. It is not the default on purpose: copying extensions from a request you did not write lets its author ask for anything, basicConstraints CA:TRUE included. Only use it on requests you control, or replace it with an explicit -extfile.

Read that No extensions in certificate message for what it is: x509 -ext prints it whenever the extension you asked for is absent, including on a certificate that carries several others, and it exits 0 either way. A script checking for a SAN has to match the output text, not the exit code.

Converting

PEM to DER and back

PEM is the base64 text form with BEGIN CERTIFICATE markers, DER is the raw binary. Java and Windows tooling often want DER.

openssl x509 -in cert.pem -outform der -out cert.der
openssl x509 -inform der -in cert.der -out back.pem

The round trip is byte-identical.

PEM to PKCS#12

A .p12 (or .pfx) is a single password-protected file carrying the key and the certificate together, which is what Java keystores, Windows and many clients expect.

openssl pkcs12 -export -inkey key.pem -in cert.pem -out bundle.p12 \
  -name "example.local" -passout pass:secret

And back out:

openssl pkcs12 -in bundle.p12 -passin pass:secret -nokeys  -out cert-only.pem
openssl pkcs12 -in bundle.p12 -passin pass:secret -nocerts -noenc -out key-only.pem

pass:secret puts the password on the command line, so it lands in your shell history and in ps. Fine in a throwaway sandbox. Anywhere else, both -passin and -passout also accept file:/path/to/file and env:VARNAME.

Removing a passphrase from a key

openssl pkey -in enc.key -out plain.key

It prompts for the current passphrase and writes the key unencrypted. Add -passin pass:... to script it, with the same caveat as above. The reverse, encrypting a bare key, is openssl pkey -in plain.key -aes-256-cbc -out enc.key.

References

Related

Keyboard shortcuts

/Open search
CtrlKOpen search (Cmd+K on macOS)
?Show this cheat sheet
Ctrl/Show this cheat sheet (Cmd+/ on macOS)
EscClose search or this cheat sheet

Turns off the single-key shortcuts (/, ?). Ctrl/Cmd+K and Ctrl/Cmd+/ stay.