Your VPN answers connections at 3 a.m. while the machine that signs its certificates sits powered off in a drawer. A client validating the server certificate never contacts your CA, so the CA does not need to be reachable for any connection. The part that needs planning is the day a certificate must be issued, renewed, or revoked while the root key stays locked away.

What a connection actually validates

When the OpenVPN client connects, it walks the signature chain from the server leaf up to a CA certificate it already trusts, checks the validity window, and, if the profile says so, consults a revocation list. It never looks up your root CA. The server does the mirror image for client certificates. At connect time only the leaf, the intermediate and the trust bundle you distributed earlier are present.

What actually fails is one of three things: the signing key is unavailable at issuance, a revocation list has gone stale, or the client trust bundle is older than the chain the server presents. The first is a process problem; the other two are distribution problems. None of them requires the root key to be online, and a managed VPN service that demands a private certificate for tunnel authentication only moves the signing authority to the provider. Azure point-to-site works the other way: you upload a root certificate and certificates signed beneath it are trusted, so ask who signs, who validates, and how a certificate is withdrawn early.

Build the root on a host that never gets an address

An offline root is used rarely: to sign the intermediate CA certificate, and later to renew or revoke it. Everything else happens one level down. Do the root work on a machine kept off the network, from a live system if possible, and record who was in the room. The commands assume OpenSSL 1.1.1 or newer, because -addext is not available in older builds.

openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out root.key
openssl req -x509 -new -key root.key -sha256 -days 7300 \
  -subj "/CN=Example VPN Root CA" -out root.crt \
  -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign,cRLSign"

Create the intermediate on the signing host and carry only its request to the offline machine. The root signs that request with a path length of zero, so the intermediate may sign endpoint certificates but may not create another CA beneath it. That one extension stops a compromised signing host from minting a root-equivalent authority. Use ECDSA only if every client runs OpenVPN 2.4 or newer; otherwise generate RSA keys and keep the flow identical.

openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out intermediate.key
openssl req -new -key intermediate.key -subj "/CN=Example VPN Intermediate CA" -out intermediate.csr
openssl x509 -req -in intermediate.csr -CA root.crt -CAkey root.key \
  -CAcreateserial -days 3650 -sha256 -extfile intermediate.ext -out intermediate.crt

intermediate.ext holds basicConstraints=critical,CA:TRUE,pathlen:0, keyUsage=critical,keyCertSign,cRLSign and authorityKeyIdentifier=keyid:always. Return the root key to its safe before the signing host is reconnected. If the root must be able to revoke the intermediate, sign it with openssl ca -config root.cnf -extensions intermediate_ca against a root-level index.txt instead of openssl x509 -req, so the serial is recorded; then ship the resulting root list inside the client profile, where a file-based crl-verify reads it at startup. Ship clients a bundle with both CA certificates; a leaf alone will not verify against the root.

Issue leaves from the intermediate, with a database

Signing with openssl x509 -req works until you need to revoke, because nothing records the serial. Use openssl ca from the first certificate so serial, expiry and revocation state live in one database. Create the database files once, then point the config at them:

mkdir -p /etc/vpn-ca/intermediate/newcerts
touch /etc/vpn-ca/intermediate/index.txt
echo 1000 > /etc/vpn-ca/intermediate/serial
echo 1000 > /etc/vpn-ca/intermediate/crlnumber
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = /etc/vpn-ca/intermediate
database = $dir/index.txt
new_certs_dir = $dir/newcerts
serial = $dir/serial
crlnumber = $dir/crlnumber
certificate = $dir/intermediate.crt
private_key = $dir/intermediate.key
default_md = sha256
default_days = 365
default_crl_days = 30
unique_subject = no
policy = policy_any
[ policy_any ]
commonName = supplied
[ server_cert ]
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = serverAuth
subjectAltName = @server_names
[ client_cert ]
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
[ server_names ]
DNS.1 = vpn.example.com

The key usage line assumes an ECDSA key; add keyEncipherment for RSA. Give every client its own common name, because OpenVPN identifies a connection by that field, and two laptops sharing CN=vpn-client cannot be told apart in logs, in client-config-dir rules or in any per-user push, and you lose the attribution that makes a revocation decision defensible. Issue and verify a leaf like this:

openssl req -new -key alice.key -subj "/CN=alice-laptop" -out alice.csr
openssl ca -config intermediate.cnf -extensions client_cert -in alice.csr -out alice.crt -batch
openssl verify -CAfile ca-bundle.crt -untrusted intermediate.crt alice.crt

Keep the signing key behind a policy boundary instead of copying it to the VPN host: a small service that authenticates the request, checks the common name against an allowlist, and returns only the certificate. Log each serial; the log is the only link between a certificate and the person who asked for it.

Publish revocation without waking the root

Client certificates are revoked by the intermediate, never by the root. The root list matters only when an intermediate is burned. Point the server at the intermediate list with crl-verify. A file-based CRL is loaded at service start, so reload after each regeneration; the directory form reads state at connect time and costs one file per serial.

Revoke and regenerate with two commands from the same database:

openssl ca -config intermediate.cnf -revoke /etc/vpn-ca/intermediate/newcerts/1001.pem
openssl ca -config intermediate.cnf -gencrl -out /etc/openvpn/crl.pem

Then watch the clock. A CRL carries a next-update field, and once that date passes a verifying peer rejects the list instead of ignoring it. Regenerating monthly with default_crl_days = 30 leaves slack; regenerating yearly does not. Shorter leaf lifetimes are the more dependable control, because revocation only applies where the list is consulted.

Test the chain, then write the ceremony down

Reproduce four failures before trusting the design. Verify the chain with openssl verify -CAfile ca-bundle.crt -untrusted intermediate.crt server.crt. Connect with an expired client certificate and confirm the server refuses it. Revoke a test client, reload, and confirm the list takes effect. Move a host clock forward a day, because a wrong clock can issue and reject certificates that look valid elsewhere.

An offline root is only a control if the paperwork exists: two named people for the ceremony, a record of every serial signed, an encrypted backup stored apart from the machine, and a restore test on an isolated host. Sign a spare intermediate during the same ceremony so a dead signing host does not force you to unlock the root at midnight.

Before you call it finished, confirm five things: the root key is not on a networked host, the intermediate key lives only on the signing host, the client bundle carries both CA certificates, the CRL has a future next-update, and someone owns a calendar entry for every expiry in the chain. If one is missing, the setup that looks offline is just undocumented.

Artiglyn mynjee