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 versionThree 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.keychmod 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.keyprime256v1, 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.keyRead 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 -verifyThe 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.crtWithout -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 subjectAltNameThat 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.
openssl x509 -in dev.crt -outform der -out dev.der
openssl x509 -inform der -in dev.der -out dev-roundtrip.pemThe 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.passWithout -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 -subjectSubstitute 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/nullFill 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.