OpenVPN PKI with OpenSSL — Certificate Management Reference

Building and managing a complete OpenVPN Certificate Authority using raw OpenSSL commands — without Easy-RSA, giving you full control over every certificate parameter, extension, and lifecycle.

CA · server cert · client cert · CRL · ECDSA · RSA · openssl.cnf · tls-crypt
01 — Why Raw OpenSSL?

Easy-RSA is a convenience wrapper around OpenSSL. Using OpenSSL directly gives you complete control: custom extensions, non-standard key sizes, automated scripting, integration into existing PKI infrastructure, and a deep understanding of what is actually happening at each step.

Every certificate Easy-RSA creates, OpenSSL can create too — Easy-RSA just hides the config files and flags. Learning the underlying commands makes you independent of any wrapper tool.
File typeExtensionContents
Private key.keySecret key material — never leaves its host. PEM-encoded.
Certificate Signing Request.csrPublic key + subject info, signed by the key owner. Sent to the CA.
Certificate.crtPublic key + subject info + CA signature. Safe to distribute.
CA certificateca.crtThe trust anchor. Distributed to all clients and the server.
DH parametersdh.pemDiffie-Hellman parameters for key exchange (RSA mode only).
Certificate Revocation Listcrl.pemList of revoked certificates. Must be kept current on the server.
TLS-crypt keyta.keyPre-shared key for authenticating the TLS handshake.
02 — Directory Structure

Set up a clean, locked-down PKI directory before issuing any certificates. This structure mirrors what Easy-RSA creates internally.

mkdir -p ~/openvpn-pki/{ca,server,clients,crl,csr} cd ~/openvpn-pki # Lock down the root — only owner can read chmod 700 ~/openvpn-pki # The directory layout: # ~/openvpn-pki/ # ├── ca/ # │ ├── ca.key ← CA private key — KEEP OFFLINE # │ └── ca.crt ← CA certificate — distribute to all # ├── server/ # │ ├── server.key # │ └── server.crt # ├── clients/ # │ ├── client1.key # │ ├── client1.crt # │ └── ... # ├── csr/ ← temporary CSR files # ├── crl/ # │ └── crl.pem ← Certificate Revocation List # ├── dh.pem ← DH parameters # ├── ta.key ← tls-crypt pre-shared key # ├── index.txt ← OpenSSL certificate database # ├── serial ← next certificate serial number # └── openssl.cnf ← CA configuration
# Initialise the CA database files touch ~/openvpn-pki/index.txt echo 1000 > ~/openvpn-pki/serial echo 1000 > ~/openvpn-pki/crlnumber
03 — openssl.cnf — The CA Configuration

The openssl.cnf file drives all CA operations. It defines the directory paths, default certificate lifetime, required extensions for server and client certificates, and the CRL configuration. This single file is what separates a properly constrained PKI from a dangerously permissive one.

~/openvpn-pki/openssl.cnf
[ ca ] default_ca = CA_default [ CA_default ] dir = /root/openvpn-pki # ← change to your actual path certs = $dir/clients crl_dir = $dir/crl database = $dir/index.txt new_certs_dir = $dir/clients certificate = $dir/ca/ca.crt serial = $dir/serial crlnumber = $dir/crlnumber crl = $dir/crl/crl.pem private_key = $dir/ca/ca.key RANDFILE = $dir/.rand x509_extensions = usr_cert name_opt = ca_default cert_opt = ca_default default_days = 825 # certificate validity (~2 years) default_crl_days = 30 # CRL validity — regenerate before expiry default_md = sha256 preserve = no policy = policy_strict [ policy_strict ] countryName = match stateOrProvinceName = match organizationName = match organizationalUnitName = optional commonName = supplied emailAddress = optional [ policy_loose ] countryName = optional stateOrProvinceName = optional localityName = optional organizationName = optional organizationalUnitName = optional commonName = supplied emailAddress = optional [ req ] default_bits = 4096 default_md = sha256 default_keyfile = privkey.pem distinguished_name = req_distinguished_name x509_extensions = v3_ca string_mask = utf8only prompt = no [ req_distinguished_name ] countryName = PT stateOrProvinceName = Porto localityName = Porto organizationName = MyOrg organizationalUnitName = VPN commonName = MyOrg VPN CA emailAddress = admin@example.com # ── Certificate extensions ──────────────────────────────────────────── [ v3_ca ] # Root CA extensions subjectKeyIdentifier = hash authorityKeyIdentifier = keyid:always,issuer basicConstraints = critical, CA:true keyUsage = critical, digitalSignature, cRLSign, keyCertSign [ v3_intermediate_ca ] # Intermediate CA (if using a two-tier PKI) subjectKeyIdentifier = hash authorityKeyIdentifier = keyid:always,issuer basicConstraints = critical, CA:true, pathlen:0 keyUsage = critical, digitalSignature, cRLSign, keyCertSign [ server_cert ] # Server certificate extensions basicConstraints = CA:FALSE nsCertType = server nsComment = "OpenVPN Server Certificate" subjectKeyIdentifier = hash authorityKeyIdentifier = keyid,issuer:always keyUsage = critical, digitalSignature, keyEncipherment extendedKeyUsage = serverAuth # Optional: add a Subject Alternative Name # subjectAltName = @alt_names [ usr_cert ] # Client certificate extensions basicConstraints = CA:FALSE nsCertType = client, email nsComment = "OpenVPN Client Certificate" subjectKeyIdentifier = hash authorityKeyIdentifier = keyid,issuer keyUsage = critical, nonRepudiation, digitalSignature, keyEncipherment extendedKeyUsage = clientAuth [ crl_ext ] authorityKeyIdentifier = keyid:always [ ocsp ] basicConstraints = CA:FALSE subjectKeyIdentifier = hash authorityKeyIdentifier = keyid,issuer keyUsage = critical, digitalSignature extendedKeyUsage = critical, OCSPSigning
⚠  Update the dir path in [ CA_default ] to match your actual PKI directory. All relative paths in the config are resolved against this value.
04 — Building the CA

The CA key is the root of trust for your entire PKI. Generate it once, protect it with a strong passphrase, and ideally keep it on an air-gapped machine or encrypted offline storage.

Option A — ECDSA CA (recommended — smaller, faster)
cd ~/openvpn-pki # Generate the CA private key using P-384 curve openssl ecparam -name secp384r1 -genkey -noout \ | openssl ec -aes256 -out ca/ca.key # Self-sign the CA certificate — valid for 10 years openssl req -new -x509 \ -key ca/ca.key \ -out ca/ca.crt \ -days 3650 \ -extensions v3_ca \ -config openssl.cnf # Lock down the key chmod 400 ca/ca.key
Option B — RSA CA (wider compatibility)
cd ~/openvpn-pki # Generate a 4096-bit RSA CA key with AES-256 passphrase protection openssl genrsa -aes256 -out ca/ca.key 4096 # Self-sign the CA certificate openssl req -new -x509 \ -key ca/ca.key \ -out ca/ca.crt \ -days 3650 \ -extensions v3_ca \ -config openssl.cnf chmod 400 ca/ca.key
Verify the CA certificate
# Inspect the certificate openssl x509 -in ca/ca.crt -text -noout # Check key fields: Issuer, Subject, Validity, Basic Constraints (CA:TRUE) openssl x509 -in ca/ca.crt -noout \ -subject -issuer -dates -fingerprint -sha256
05 — Server Certificate

The server certificate uses the server_cert extension block which sets extendedKeyUsage = serverAuth. This restricts the certificate to server authentication — it cannot be used as a client certificate.

ECDSA server key and certificate
cd ~/openvpn-pki # 1. Generate the server private key (no passphrase — sshd needs to start unattended) openssl ecparam -name secp384r1 -genkey -noout -out server/server.key chmod 400 server/server.key # 2. Generate the Certificate Signing Request openssl req -new \ -key server/server.key \ -out csr/server.csr \ -subj "/C=PT/ST=Porto/L=Porto/O=MyOrg/OU=VPN/CN=vpn-server" # 3. Sign the CSR with the CA — uses server_cert extensions openssl ca \ -config openssl.cnf \ -extensions server_cert \ -days 825 \ -notext \ -in csr/server.csr \ -out server/server.crt # 4. Verify openssl verify -CAfile ca/ca.crt server/server.crt openssl x509 -in server/server.crt -text -noout | grep -A2 "Extended Key"
RSA server key and certificate
cd ~/openvpn-pki # 1. Generate RSA key openssl genrsa -out server/server.key 4096 chmod 400 server/server.key # 2. CSR openssl req -new \ -key server/server.key \ -out csr/server.csr \ -subj "/C=PT/ST=Porto/L=Porto/O=MyOrg/OU=VPN/CN=vpn-server" # 3. Sign openssl ca \ -config openssl.cnf \ -extensions server_cert \ -days 825 \ -notext \ -in csr/server.csr \ -out server/server.crt # 4. Verify openssl verify -CAfile ca/ca.crt server/server.crt
06 — Client Certificates

Each VPN user or device gets its own certificate. The Common Name (CN) is used by OpenVPN as the client identifier — it appears in the status log and can be used for per-client routing rules. Use a consistent naming scheme.

Generate a client certificate (ECDSA)
cd ~/openvpn-pki CLIENT="alice" # change for each client # 1. Private key openssl ecparam -name secp384r1 -genkey -noout -out clients/${CLIENT}.key chmod 400 clients/${CLIENT}.key # 2. CSR — CN must be unique per client openssl req -new \ -key clients/${CLIENT}.key \ -out csr/${CLIENT}.csr \ -subj "/C=PT/ST=Porto/L=Porto/O=MyOrg/OU=VPN/CN=${CLIENT}" # 3. Sign with CA using client (usr_cert) extensions openssl ca \ -config openssl.cnf \ -extensions usr_cert \ -days 825 \ -notext \ -in csr/${CLIENT}.csr \ -out clients/${CLIENT}.crt # 4. Verify openssl verify -CAfile ca/ca.crt clients/${CLIENT}.crt
Generate a client certificate (RSA)
CLIENT="bob" openssl genrsa -out clients/${CLIENT}.key 4096 chmod 400 clients/${CLIENT}.key openssl req -new \ -key clients/${CLIENT}.key \ -out csr/${CLIENT}.csr \ -subj "/C=PT/ST=Porto/L=Porto/O=MyOrg/OU=VPN/CN=${CLIENT}" openssl ca \ -config openssl.cnf \ -extensions usr_cert \ -days 825 \ -notext \ -in csr/${CLIENT}.csr \ -out clients/${CLIENT}.crt openssl verify -CAfile ca/ca.crt clients/${CLIENT}.crt
Inspect a certificate
# Full dump openssl x509 -in clients/alice.crt -text -noout # Quick summary — subject, dates, fingerprint openssl x509 -in clients/alice.crt -noout \ -subject -dates -fingerprint -sha256 # Check it was signed by your CA openssl verify -CAfile ca/ca.crt clients/alice.crt # List all issued certs from the CA database cat ~/openvpn-pki/index.txt
07 — DH Parameters & TLS-Crypt Key
Diffie-Hellman parameters (RSA mode only)
cd ~/openvpn-pki # Generate 2048-bit DH params (fast) or 4096-bit (slower but stronger) openssl dhparam -out dh.pem 2048 # 4096-bit — takes several minutes openssl dhparam -out dh.pem 4096 # Verify openssl dhparam -in dh.pem -check -text | head -5
ℹ  If you're using ECDH key exchange in server.conf (dh none + ecdh-curve secp384r1), skip DH parameter generation entirely — it's not needed with ECDH.
TLS-crypt pre-shared key
# Generate the tls-crypt key (same regardless of RSA or ECDSA) openvpn --genkey secret ta.key # Inspect it cat ta.key
08 — Certificate Revocation (CRL)

When a client certificate needs to be invalidated — lost device, terminated user, compromise — you revoke it and regenerate the CRL. OpenVPN checks the CRL on every connection attempt when crl-verify is set in server.conf.

Create the initial (empty) CRL
cd ~/openvpn-pki # Generate an initial empty CRL — do this before starting OpenVPN openssl ca \ -config openssl.cnf \ -gencrl \ -out crl/crl.pem # Verify and inspect the CRL openssl crl -in crl/crl.pem -text -noout
Revoke a client certificate
CLIENT="alice" cd ~/openvpn-pki # Revoke the certificate openssl ca \ -config openssl.cnf \ -revoke clients/${CLIENT}.crt # Regenerate the CRL to include the revoked cert openssl ca \ -config openssl.cnf \ -gencrl \ -out crl/crl.pem # Verify alice's cert is now in the CRL openssl crl -in crl/crl.pem -text -noout | grep -A3 "Revoked" # Deploy the updated CRL to the OpenVPN server sudo cp crl/crl.pem /etc/openvpn/server/crl.pem # Reload OpenVPN to pick up the new CRL (no connections dropped) sudo systemctl reload openvpn-server@server
Check CRL expiry
# CRLs have a validity period (default_crl_days in openssl.cnf) # Check when the current CRL expires openssl crl -in crl/crl.pem -noout -nextupdate # If the CRL expires, OpenVPN rejects ALL connections # Regenerate before expiry — automate this with a cron job: # /etc/cron.d/openvpn-crl # 0 3 * * 1 root cd /root/openvpn-pki && \ # openssl ca -config openssl.cnf -gencrl -out crl/crl.pem && \ # cp crl/crl.pem /etc/openvpn/server/ && \ # systemctl reload openvpn-server@server
⚠  The CRL has an expiry date (default_crl_days in openssl.cnf). If the CRL expires, OpenVPN will refuse all connections. Automate CRL renewal and monitor its expiry date.
09 — Automating Certificate Operations

A shell script that wraps the common operations — creating a client cert, generating the .ovpn profile, and revoking a client — into simple single commands.

vpn-pki.sh — complete PKI management script
#!/bin/bash # vpn-pki.sh — OpenVPN PKI management with raw OpenSSL # Usage: # ./vpn-pki.sh new-client <name> # ./vpn-pki.sh gen-ovpn <name> # ./vpn-pki.sh revoke <name> # ./vpn-pki.sh list # ./vpn-pki.sh crl-renew set -euo pipefail PKI_DIR="/root/openvpn-pki" CNF="$PKI_DIR/openssl.cnf" CLIENTS_DIR="$PKI_DIR/clients" CSR_DIR="$PKI_DIR/csr" SERVER_IP="YOUR_SERVER_IP" SERVER_PORT="1194" PROTO="udp" OUTPUT_DIR="/root/ovpn-profiles" mkdir -p "$OUTPUT_DIR" # ── Helper ──────────────────────────────────────────────────────────── die() { echo "ERROR: $*" >&2; exit 1; } # ── new-client ──────────────────────────────────────────────────────── cmd_new_client() { local name="$1" [[ -z "$name" ]] && die "Usage: $0 new-client <name>" [[ -f "$CLIENTS_DIR/${name}.crt" ]] && die "Client '$name' already exists" echo "==> Generating key for $name ..." openssl ecparam -name secp384r1 -genkey -noout \ -out "$CLIENTS_DIR/${name}.key" chmod 400 "$CLIENTS_DIR/${name}.key" echo "==> Creating CSR ..." openssl req -new \ -key "$CLIENTS_DIR/${name}.key" \ -out "$CSR_DIR/${name}.csr" \ -subj "/C=PT/ST=Porto/L=Porto/O=MyOrg/OU=VPN/CN=${name}" echo "==> Signing certificate ..." openssl ca \ -config "$CNF" \ -extensions usr_cert \ -days 825 \ -notext \ -batch \ -in "$CSR_DIR/${name}.csr" \ -out "$CLIENTS_DIR/${name}.crt" openssl verify -CAfile "$PKI_DIR/ca/ca.crt" "$CLIENTS_DIR/${name}.crt" echo "==> Client '$name' created successfully." } # ── gen-ovpn ────────────────────────────────────────────────────────── cmd_gen_ovpn() { local name="$1" [[ -z "$name" ]] && die "Usage: $0 gen-ovpn <name>" [[ ! -f "$CLIENTS_DIR/${name}.crt" ]] && die "Client '$name' not found" local out="$OUTPUT_DIR/${name}.ovpn" cat > "$out" <<EOF client dev tun proto ${PROTO} remote ${SERVER_IP} ${SERVER_PORT} resolv-retry infinite nobind persist-key persist-tun cipher AES-256-GCM auth SHA256 tls-version-min 1.2 key-direction 1 verb 3 EOF printf "\n<ca>\n" >> "$out" cat "$PKI_DIR/ca/ca.crt" >> "$out" printf "</ca>\n" >> "$out" printf "\n<cert>\n" >> "$out" openssl x509 -in "$CLIENTS_DIR/${name}.crt" >> "$out" printf "</cert>\n" >> "$out" printf "\n<key>\n" >> "$out" cat "$CLIENTS_DIR/${name}.key" >> "$out" printf "</key>\n" >> "$out" printf "\n<tls-crypt>\n" >> "$out" cat "$PKI_DIR/ta.key" >> "$out" printf "</tls-crypt>\n" >> "$out" chmod 600 "$out" echo "==> Profile written to: $out" } # ── revoke ──────────────────────────────────────────────────────────── cmd_revoke() { local name="$1" [[ -z "$name" ]] && die "Usage: $0 revoke <name>" [[ ! -f "$CLIENTS_DIR/${name}.crt" ]] && die "Client '$name' not found" echo "==> Revoking certificate for $name ..." openssl ca -config "$CNF" -revoke "$CLIENTS_DIR/${name}.crt" echo "==> Regenerating CRL ..." openssl ca -config "$CNF" -gencrl -out "$PKI_DIR/crl/crl.pem" echo "==> Deploying CRL ..." sudo cp "$PKI_DIR/crl/crl.pem" /etc/openvpn/server/crl.pem sudo systemctl reload openvpn-server@server # Archive the revoked files mkdir -p "$PKI_DIR/revoked" mv "$CLIENTS_DIR/${name}.crt" "$PKI_DIR/revoked/${name}.crt" mv "$CLIENTS_DIR/${name}.key" "$PKI_DIR/revoked/${name}.key" 2>/dev/null || true rm -f "$CSR_DIR/${name}.csr" echo "==> Client '$name' revoked." } # ── list ────────────────────────────────────────────────────────────── cmd_list() { echo "" echo "Active clients:" echo "───────────────────────────────────────────────────────" printf "%-20s %-12s %-12s %s\n" "NAME" "ISSUED" "EXPIRES" "STATUS" echo "───────────────────────────────────────────────────────" for crt in "$CLIENTS_DIR"/*.crt; do [[ -f "$crt" ]] || continue name=$(basename "$crt" .crt) issued=$(openssl x509 -in "$crt" -noout -startdate 2>/dev/null \ | cut -d= -f2 | awk '{print $2,$4}') expires=$(openssl x509 -in "$crt" -noout -enddate 2>/dev/null \ | cut -d= -f2 | awk '{print $2,$4}') printf "%-20s %-12s %-12s %s\n" "$name" "$issued" "$expires" "ACTIVE" done echo "" } # ── crl-renew ───────────────────────────────────────────────────────── cmd_crl_renew() { echo "==> Renewing CRL ..." openssl ca -config "$CNF" -gencrl -out "$PKI_DIR/crl/crl.pem" sudo cp "$PKI_DIR/crl/crl.pem" /etc/openvpn/server/crl.pem sudo systemctl reload openvpn-server@server expires=$(openssl crl -in "$PKI_DIR/crl/crl.pem" -noout -nextupdate) echo "==> CRL renewed. $expires" } # ── Dispatch ────────────────────────────────────────────────────────── case "${1:-}" in new-client) cmd_new_client "${2:-}" ;; gen-ovpn) cmd_gen_ovpn "${2:-}" ;; revoke) cmd_revoke "${2:-}" ;; list) cmd_list ;; crl-renew) cmd_crl_renew ;; *) echo "Usage: $0 {new-client|gen-ovpn|revoke|list|crl-renew} [name]" exit 1 ;; esac
chmod +x vpn-pki.sh # Usage examples ./vpn-pki.sh new-client alice # create cert for alice ./vpn-pki.sh gen-ovpn alice # generate alice.ovpn ./vpn-pki.sh list # list all active clients ./vpn-pki.sh revoke alice # revoke alice's certificate ./vpn-pki.sh crl-renew # renew the CRL
10 — Certificate Inspection Cheat-Sheet
CommandWhat it shows
openssl x509 -in cert.crt -text -nooutFull certificate dump — all fields and extensions
openssl x509 -in cert.crt -noout -subject -issuer -datesSubject, issuer, and validity dates
openssl x509 -in cert.crt -noout -fingerprint -sha256SHA-256 fingerprint
openssl x509 -in cert.crt -noout -purposeWhat the cert can be used for (server/client auth)
openssl verify -CAfile ca.crt cert.crtVerify the cert was signed by your CA
openssl crl -in crl.pem -text -nooutFull CRL dump — list of revoked serials
openssl crl -in crl.pem -noout -nextupdateWhen the CRL expires
openssl req -in cert.csr -text -nooutInspect a CSR before signing
cat index.txtCA database — all issued/revoked certs
openssl ec -in key.key -text -nooutInspect an EC private key
openssl rsa -in key.key -text -nooutInspect an RSA private key
openssl dhparam -in dh.pem -checkVerify DH parameters
11 — Quick Reference
OperationCommand
Generate ECDSA key (P-384)openssl ecparam -name secp384r1 -genkey -noout -out key.key
Generate RSA key (4096)openssl genrsa -out key.key 4096
Create CSRopenssl req -new -key key.key -out cert.csr -subj "/CN=name"
Self-sign CA certopenssl req -new -x509 -key ca.key -out ca.crt -days 3650 -extensions v3_ca -config openssl.cnf
Sign server CSRopenssl ca -config openssl.cnf -extensions server_cert -days 825 -notext -in server.csr -out server.crt
Sign client CSRopenssl ca -config openssl.cnf -extensions usr_cert -days 825 -notext -in client.csr -out client.crt
Generate DH paramsopenssl dhparam -out dh.pem 2048
Generate tls-crypt keyopenvpn --genkey secret ta.key
Create initial CRLopenssl ca -config openssl.cnf -gencrl -out crl.pem
Revoke a certificateopenssl ca -config openssl.cnf -revoke client.crt
Regenerate CRL after revokeopenssl ca -config openssl.cnf -gencrl -out crl.pem
Verify a certificateopenssl verify -CAfile ca.crt cert.crt
✔  The complete setup order: create directory structure → write openssl.cnf → generate CA key → self-sign CA cert → generate server key + CSR → sign server cert → generate DH params → generate ta.key → generate CRL → copy to /etc/openvpn/server → start OpenVPN. Then for each client: generate key → create CSR → sign → assemble .ovpn.