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 type | Extension | Contents |
| Private key | .key | Secret key material — never leaves its host. PEM-encoded. |
| Certificate Signing Request | .csr | Public key + subject info, signed by the key owner. Sent to the CA. |
| Certificate | .crt | Public key + subject info + CA signature. Safe to distribute. |
| CA certificate | ca.crt | The trust anchor. Distributed to all clients and the server. |
| DH parameters | dh.pem | Diffie-Hellman parameters for key exchange (RSA mode only). |
| Certificate Revocation List | crl.pem | List of revoked certificates. Must be kept current on the server. |
| TLS-crypt key | ta.key | Pre-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
| Command | What it shows |
| openssl x509 -in cert.crt -text -noout | Full certificate dump — all fields and extensions |
| openssl x509 -in cert.crt -noout -subject -issuer -dates | Subject, issuer, and validity dates |
| openssl x509 -in cert.crt -noout -fingerprint -sha256 | SHA-256 fingerprint |
| openssl x509 -in cert.crt -noout -purpose | What the cert can be used for (server/client auth) |
| openssl verify -CAfile ca.crt cert.crt | Verify the cert was signed by your CA |
| openssl crl -in crl.pem -text -noout | Full CRL dump — list of revoked serials |
| openssl crl -in crl.pem -noout -nextupdate | When the CRL expires |
| openssl req -in cert.csr -text -noout | Inspect a CSR before signing |
| cat index.txt | CA database — all issued/revoked certs |
| openssl ec -in key.key -text -noout | Inspect an EC private key |
| openssl rsa -in key.key -text -noout | Inspect an RSA private key |
| openssl dhparam -in dh.pem -check | Verify DH parameters |
11 — Quick Reference
| Operation | Command |
| 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 CSR | openssl req -new -key key.key -out cert.csr -subj "/CN=name" |
| Self-sign CA cert | openssl req -new -x509 -key ca.key -out ca.crt -days 3650 -extensions v3_ca -config openssl.cnf |
| Sign server CSR | openssl ca -config openssl.cnf -extensions server_cert -days 825 -notext -in server.csr -out server.crt |
| Sign client CSR | openssl ca -config openssl.cnf -extensions usr_cert -days 825 -notext -in client.csr -out client.crt |
| Generate DH params | openssl dhparam -out dh.pem 2048 |
| Generate tls-crypt key | openvpn --genkey secret ta.key |
| Create initial CRL | openssl ca -config openssl.cnf -gencrl -out crl.pem |
| Revoke a certificate | openssl ca -config openssl.cnf -revoke client.crt |
| Regenerate CRL after revoke | openssl ca -config openssl.cnf -gencrl -out crl.pem |
| Verify a certificate | openssl 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.