All writing
Updated 7 min readNaren

OpenSSL commands worth remembering

Generate a key, make a CSR, inspect a certificate, debug a TLS handshake, and convert between the formats that keep breaking.

Certificates come up twice a year, which is exactly why nobody remembers the commands. This is the short list worth keeping. Everything below was run against OpenSSL 3.6.3.

One openssl binary covers all of it: keys, certificates, and TLS, the transport layer security protocol behind the padlock in a browser's address bar.

The 3.x line added providers, which supply the algorithm implementations, and that is why an old file sometimes needs a -legacy flag to open.

Which openssl you actually have

openssl version

Three seconds, and it decides whether the rest of this post applies. On macOS /usr/bin/openssl answers LibreSSL 3.3.6, which is not OpenSSL.

LibreSSL is a fork carrying the same command name and a different flag set: -noenc, pkcs12 -legacy and ciphers -s do not exist there, and you get a usage dump rather than a clear error.

Run brew install openssl@3 and use that binary.

Containers are the other surprise: an image built FROM scratch, or on a distroless base that ships only a language runtime and nothing else, has no openssl at all, and Alpine needs apk add openssl.

Any 3.x or 4.x binary runs every command below. If openssl version says 3.0, the line most long-term-support distributions still ship, OpenSSL's release table has it going end of life on 7 September 2026. That is a reason to upgrade the machine, not to change these commands.

Generate a private key

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out server.key
chmod 600 server.key

chmod 600 sets the file so only your own user account can read or write it.

Run it in the same breath as the keygen, not later: the gap where a fresh private key sits world-readable is the whole problem.

RSA is the public-key algorithm most certificates still use, and its one parameter is the key size. 2048 bits is a floor, not a recommendation.

NIST SP 800-131A permits RSA moduli from 2048 up to (but not including) 3072 bits through 31 December 2030 and deprecates them for signature generation after that, so a key still in service in 2031 wants 3072. How RSA works explains the number.

An elliptic curve key is smaller and faster. A 256-bit EC key is roughly as hard to break as a 3072-bit RSA key. The two numbers are not directly comparable, because the algorithms are attacked in different ways:

openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:prime256v1 -out server-ec.key
chmod 600 server-ec.key

prime256v1, P-256 and secp256r1 are three names for one curve; you will meet all three.

ECDSA is the signature algorithm normally used with such a key, which is why these get called ECDSA keys; the key itself is an EC key and does key agreement too. Every recipe below works with it.

Supply the passphrase without a prompt

An encrypted private key needs a passphrase typed in, which is fine at a terminal and fatal in a script: openssl genpkey ... -aes256 with no password flag stops at Enter PEM pass phrase: and fails with Error writing key(s) when stdin is empty.

PEM in that prompt is the base64 text format OpenSSL writes keys and certificates in by default.

printf 'your-passphrase-here' > key.pass && chmod 600 key.pass
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
  -aes256 -pass file:key.pass -out server-encrypted.key

Read the phrase from a file rather than an environment variable. OpenSSL's documentation says pass: is visible to utilities like ps, and that env: needs caution because process environments are readable on some platforms: on Linux, through /proc/<pid>/environ.

A chmod 600 file is the dull option that leaks least.

key.pass must never reach a repository; if nothing checks that on the way in, git hooks that stop secrets before they land is the cheap version.

Make a signing request with the names already in it

A certificate signing request, or CSR, is a file holding your public key and the names you want on the certificate, signed by your private key to prove you hold it.

You send it to a certificate authority, or CA. That is an organisation whose signature browsers already trust, so anything it signs is trusted too.

Back comes an X.509 certificate: your public key plus the identity information, signed by the CA.

Keys, certificates and CAs together are public key infrastructure, PKI. Your private key never leaves your machine.

openssl req -new -key server.key -out server.csr \
  -subj "/C=US/ST=California/L=San Francisco/O=Example Corp/CN=api.example.com" \
  -addext "subjectAltName=DNS:api.example.com,DNS:www.api.example.com"

openssl req -in server.csr -noout -verify

The second command prints Certificate request self-signature verify OK. Swap -verify for -text to read the request back in full.

C, ST, L and O are country, state, locality and organisation. CN is the common name, an older single-name field, and not what decides which hostnames the certificate covers. That is subjectAltName, the SAN: a comma-separated list where each entry is prefixed DNS: or IP:.

The CN has been out of that job for years.

RFC 9525, published November 2023, says the common name "MUST NOT be used to identify a service", and Chrome dropped the CN fallback for hostname matching in Chrome 58, April 2017.

The CA/Browser Forum is the body where browser vendors and CAs agree the rules every public certificate has to follow. It has required a SAN in its Baseline Requirements since 2012, and requires any CN present to repeat a value from the SAN list. So -addext is not optional.

Make a certificate for local development

openssl req -x509 -newkey rsa:2048 -noenc \
  -keyout dev.key -out dev.crt -days 180 \
  -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
chmod 600 dev.key

-noenc writes the generated private key without encrypting it, so a local server can start unattended. It replaces -nodes, deprecated since OpenSSL 3.0. That flag is not the word nodes: it is "no DES", after the Data Encryption Standard cipher it once switched off.

-nodes still works on 3.x and 4.x; -noenc is missing from LibreSSL, so this is the line that breaks first on an unmodified Mac.

180 days rather than a year is deliberate: the maximum lifetime of a publicly trusted TLS certificate has been 200 days since 15 March 2026, under CA/Browser Forum ballot SC-081v3, which passed unanimously on 11 April 2025.

Turn that request into a certificate

Signing your own CSR takes a CA you control: one command to create the CA, one to sign with it, then a check.

openssl req -x509 -newkey rsa:2048 -noenc -keyout ca.key -out ca.crt -days 365 \
  -subj "/CN=Example Dev CA"
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -out server.crt -days 180 \
  -copy_extensions copy
openssl verify -CAfile ca.crt server.crt

Without -copy_extensions copy, openssl drops the SAN and the certificate is useless to a browser: inspect the result and it reports No extensions in certificate.

Nothing warns you. openssl verify on the last line then checks the finished server.crt against the CA certificate that signed it.

Read what a certificate says

openssl x509 -in dev.crt -noout -subject -dates -ext subjectAltName

That prints the subject, the validity window and the SAN, usually all you wanted, instead of the sixty-odd lines -text gives you.

-noout means do not print the object back out; without it you get those details and then the whole re-encoded certificate.

Confirm a key and a certificate are a pair

diff <(openssl x509 -in dev.crt -noout -pubkey) \
     <(openssl pkey -in dev.key -pubout) \
  && echo "MATCH" || echo "MISMATCH"

One word of output, correct for RSA and EC alike because it compares the public keys themselves. openssl pkey -in dev.key -pubout is also how you extract a public key on its own.

The recipe you will find everywhere else pipes openssl x509 -noout -modulus and openssl rsa -noout -modulus through openssl md5 and compares. Do not use it. It is RSA-only.

Run it against an EC certificate and it does not fail loudly: openssl x509 -noout -modulus prints Modulus=No modulus for this public key type and exits 0. That line of text is what gets hashed, so two entirely unrelated P-256 certificates produce the same hash and the check reports a match.

A test that reports a match for an unrelated key and certificate is worse than no test.

<(...) is bash and zsh only; in POSIX sh, diff two temp files instead.

Convert between the three containers

PEM is a certificate or key stored as base64 text between -----BEGIN----- and -----END----- lines, and one PEM file can hold several objects stacked together.

DER is the same data as raw binary instead of base64.

PKCS#12, the files ending .p12 or .pfx, is different in kind: a single password-protected container holding a private key, its certificate and the chain together, for moving all three between systems.

Oracle's keytool documentation has PKCS#12 as Java's default keystore type since JDK 9.

In the diagram below, look at the two arrows running between PEM and DER: same certificate, two encodings, so the round trip is byte-identical. PKCS#12 sits apart because it carries the private key.

PEM and DER are one certificate in two encodings, base64 text or raw binary, converted with the x509 subcommand. PKCS 12 is a password protected container holding key, certificate and chain together. text you can paste raw binary key and chain together x509 -outform der x509 -inform der pkcs12 -export pkcs12 -in -noenc What do you need PEM DER p12 or pfx bundle
openssl x509 -in dev.crt -outform der -out dev.der
openssl x509 -inform der -in dev.der -out dev-roundtrip.pem

The bundle is the one with a trap in it. Drop -certfile ca_chain.pem if you have no intermediate chain to include.

Nothing earlier in this post creates one, so as written the export stops at No such file or directory.

printf 'your-export-password' > pfx.pass && chmod 600 pfx.pass

openssl pkcs12 -export -out bundle.p12 \
  -inkey dev.key -in dev.crt \
  -certfile ca_chain.pem \
  -passout file:pfx.pass

openssl pkcs12 -in bundle.p12 -out extracted.pem -noenc -passin file:pfx.pass

Without -passout the export blocks on Enter Export Password: and dies when stdin is empty, the most likely place to get stuck in a Dockerfile or a CI job.

-noenc on the way back out writes the extracted private key unencrypted.

If an older .pfx refuses to open and the error mentions unsupported and RC2-40-CBC, add -legacy. That is the provider machinery from the top of the post showing through, and the usual explanation for a .pfx that broke after an upgrade. LibreSSL has no -legacy either.

Debug a live handshake

openssl s_client -connect example.com:443 -servername example.com -brief </dev/null

-brief gives a readable summary instead of a wall of handshake output.

-servername sets SNI, server name indication: the part of the handshake where the client says which hostname it wants, so one IP address can serve many sites with different certificates.

Most virtual-hosted servers need it. </dev/null matters as much, because s_client otherwise waits on stdin forever.

Give only one protocol flag, and write it with an underscore: -tls1_3 forces TLS 1.3, while -tls1.3 returns s_client: Unknown option.

For a monitoring script, read the expiry and stop:

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

Substitute your own host. You get two dates and a subject, nothing interactive.

Mutual TLS, or mTLS, is TLS where the client presents a certificate too, so both ends prove who they are. Testing one needs the three files to exist already:

openssl s_client -connect internal.example.com:8443 \
  -cert client.crt -key client.key -CAfile ca_bundle.crt </dev/null

Fill in your own host and filenames. Omitting -CAfile falls back to your operating system's trust store, which will not contain an internal CA unless someone has installed it there.

What to use instead in production

Every command above is for diagnosis, local development and moving files between systems.

Issuing production certificates by hand fails on arithmetic alone: the 200-day maximum drops to 100 days in March 2027 and 47 days in March 2029, and Let's Encrypt has offered six-day certificates alongside its 90-day default since February 2025.

ACME is the answer to that: the protocol Let's Encrypt and similar CAs use to issue certificates with no human in the loop.

Learn these commands to find out what a certificate says and why a handshake failed, not to keep one alive.