| Tested on | Red Hat Enterprise Linux 10.2 (Coughlan) |
|---|---|
| Package | openssl 3.5.5-6.el10_2 |
| Applies to | RHEL, Rocky Linux, AlmaLinux, Oracle Linux, CentOS Stream, Fedora, Ubuntu, Debian, Kali Linux, Linux Mint, Pop!_OS, Arch Linux |
| Privilege | Normal user (a writable directory is enough) |
| Scope | Build a root CA, one intermediate CA, and a leaf server certificate; assemble ca-chain.pem and fullchain.pem; verify with openssl verify and a TLS handshake. Covers multi-level intermediate chains, pathlen, and a script for up to eight levels. Does not cover obtaining certificates from a public CA. |
| Related guides | Generate a CSR with OpenSSL |
When your browser accepts https://www.example.com, it follows a chain of signatures from the server certificate up to a root CA already in the trust store. This guide builds that chain with OpenSSL: root CA, intermediate CA, leaf certificate, then the bundle files a server sends. If terms like CSR or PKIX are new, see the PKI and certificate authority overview.
What is an OpenSSL certificate chain?
A certificate chain is an ordered list of X.509 certificates. Each certificate is signed by the one above it.
www.example.com (leaf — server certificate)
|
signed by Intermediate CA 1
|
signed by Root CA (self-signed trust anchor)At the bottom is the leaf (your server or client certificate). Above it sits at least one intermediate CA certificate. The root CA signs itself and acts as the trust anchor.
Clients walk upward from the leaf, checking each signature until they reach a root they already trust. Two bundle files show up in almost every deployment:
ca-chain.pem— intermediate certificate(s) only, ordered from the certificate that issued the leaf upward toward the root. The server sends this alongside the leaf (or concatenates it intofullchain.pem).fullchain.pem— leaf certificate first, then the intermediates in that same order. Many web servers load this single file for TLS.
With two intermediates between the leaf and root, the chain looks like this:
leaf
↓
int2
↓
int1
↓
rootca-chain.pem holds only the intermediates: int2, then int1.
Do not put the root in either bundle. The root must already be in the client trust store; use the Linux CA trust-store procedure when Debian-, Ubuntu-, RHEL-, or Fedora-family clients need to trust your private root.
Most labs use one intermediate between the root and leaf certificates so the root key can stay offline while the intermediate handles day-to-day signing. Extra intermediate levels are optional and add handshake size and key-management overhead.
Quick answer: build and verify a chain from existing certificates
If you already have PEM files from your CA, assembling and verifying the chain is three steps.
Put the intermediate certificate(s) into a chain file (one intermediate in this example):
cat intermediate.pem > ca-chain.pemAdd the leaf in front to build what many servers expect:
cat leaf.pem ca-chain.pem > fullchain.pemValidate the leaf against the root, passing intermediates as untrusted links in the path:
openssl verify -CAfile root.pem -untrusted ca-chain.pem leaf.pemleaf.pem: OKWith more than one intermediate, list certificates in ca-chain.pem starting with the one that issued the leaf, then each parent up toward the root (int2 before int1 when the leaf was signed by int2). The walkthrough below creates these files from scratch when you do not have them yet.
Build the CA directory structure
openssl ca needs a small set of state files before it can sign anything. Create the layout under your home directory:
mkdir -p "$HOME/tls-lab/root"/{certs,crl,csr,newcerts,private}Lock down the private key directory:
chmod 700 "$HOME/tls-lab/root/private"index.txt is the issuance database. OpenSSL requires the file to exist even when empty:
touch "$HOME/tls-lab/root/index.txt"The serial file holds the next serial number in hexadecimal (at least two digits):
echo 1000 > "$HOME/tls-lab/root/serial"The crlnumber file tracks CRL generations if you publish revocation lists later:
echo 0100 > "$HOME/tls-lab/root/crlnumber"Each directory and file has a distinct role:
certs— CA certificate and copies of issued certificatescsr— signing requests waiting to be processednewcerts— backup of every issued certificate, named by serial numberprivate— CA private key (not world-readable)crl— published revocation listsindex.txt,serial,crlnumber— state that turns a key pair into an operable CA
private/. Anyone who obtains that key can issue certificates your clients will trust, so on a real CA it belongs on offline media or in an HSM.
Configure the root and intermediate CA
openssl ca reads paths, policy, and extensions from a configuration file. Create $HOME/tls-lab/root/openssl.cnf with the following contents. The dir line uses $ENV::HOME so the same file works for any user account:
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = $ENV::HOME/tls-lab/root
certs = $dir/certs
crl_dir = $dir/crl
new_certs_dir = $dir/newcerts
database = $dir/index.txt
serial = $dir/serial
private_key = $dir/private/root.key.pem
certificate = $dir/certs/root.cert.pem
crl = $dir/crl/root.crl.pem
crlnumber = $dir/crlnumber
crl_extensions = crl_ext
default_crl_days = 30
default_md = sha256
preserve = no
email_in_dn = no
name_opt = ca_default
cert_opt = ca_default
policy = policy_loose
unique_subject = no
copy_extensions = none
[ policy_loose ]
countryName = optional
stateOrProvinceName = optional
localityName = optional
organizationName = optional
organizationalUnitName = optional
commonName = supplied
emailAddress = optional
[ req ]
default_bits = 2048
distinguished_name = req_distinguished_name
string_mask = utf8only
default_md = sha256
prompt = no
[ req_distinguished_name ]
countryName = Country Name
stateOrProvinceName = State
localityName = Locality
0.organizationName = Organization
organizationalUnitName = Organizational Unit
commonName = Common Name
[ crl_ext ]
authorityKeyIdentifier = keyid:always
[ v3_intermediate_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true, pathlen:0
keyUsage = critical, digitalSignature, cRLSign, keyCertSignThree settings in that file change behaviour in ways that are easy to miss:
copy_extensions = noneignores CSR extensions. Withcopy, extensions from the request are copied only when the CA profile did not already supply them.copyallis more dangerous because request extensions can replace CA profile values. If you usecopy, definebasicConstraints = CA:FALSEand appropriatekeyUsagein the CA profile so a request cannot upgrade itself to a CA certificate.policy_looseonly requires a common name. A root that should sign only your own intermediates can use a stricter policy where country and organisation must match.pathlen:0in[ v3_intermediate_ca ]is stamped into the next subordinate this CA signs. This walkthrough uses one intermediate that issues only leaf certificates.
$ENV::SAN in a CA's permanent configuration file. OpenSSL parses the whole file on every openssl ca invocation, so an unset variable fails with variable has no value. Using $ENV::HOME for dir is fine because HOME is always set. Pass per-certificate values such as SANs through -extfile at signing time.
Create the root CA
If you want an encrypted root key, CA extension checks, and a verification-only workflow before you touch openssl ca, start with create a root CA with OpenSSL. The commands below match this chain lab layout ($HOME/tls-lab) and use an unencrypted genrsa key for brevity.
Create the root private key. 4096 bits is reasonable here because this key has the longest life and the widest blast radius if it leaks:
openssl genrsa -out "$HOME/tls-lab/root/private/root.key.pem" 4096Remove all access except for the owner:
chmod 400 "$HOME/tls-lab/root/private/root.key.pem"Self-sign the root certificate. The root signs itself, so its CA extensions come from -addext on the command line. pathlen:1 allows one intermediate level beneath this root:
openssl req -new -x509 -sha256 -days 7300 -config "$HOME/tls-lab/root/openssl.cnf" -key "$HOME/tls-lab/root/private/root.key.pem" -out "$HOME/tls-lab/root/certs/root.cert.pem" -subj "/C=IN/ST=Karnataka/L=Bengaluru/O=GoLinuxCloud/OU=Lab/CN=GoLinuxCloud Root CA" -addext "basicConstraints=critical,CA:true,pathlen:1" -addext "keyUsage=critical,digitalSignature,cRLSign,keyCertSign" -addext "subjectKeyIdentifier=hash"Check that basicConstraints is present and marked critical (RFC 5280 requires basicConstraints to be critical on CA certificates used to validate certificate signatures):
openssl x509 -in "$HOME/tls-lab/root/certs/root.cert.pem" -noout -ext basicConstraintsX509v3 Basic Constraints: critical
CA:TRUE, pathlen:1critical means a conforming implementation that does not understand the extension must reject the certificate rather than ignore it. pathlen:1 is separate: it limits how many subordinate CA certificates may follow this one in a valid path. The certificate itself can be world readable:
chmod 444 "$HOME/tls-lab/root/certs/root.cert.pem"For a fuller reading of certificate fields, see view certificate contents.
Create and sign the intermediate CA
Each intermediate is a CA in its own right and needs its own directory and database. Signing int1 with the root's configuration (not int2 with the root) is what makes the chain nested rather than a set of siblings under one root.
Create the layout and state files for the intermediate:
mkdir -p "$HOME/tls-lab/int1"/{certs,crl,csr,newcerts,private} && chmod 700 "$HOME/tls-lab/int1/private"Create the intermediate's state files the same way you did for the root:
touch "$HOME/tls-lab/int1/index.txt" && echo 1000 > "$HOME/tls-lab/int1/serial" && echo 0100 > "$HOME/tls-lab/int1/crlnumber"Copy $HOME/tls-lab/root/openssl.cnf to $HOME/tls-lab/int1/openssl.cnf, then change dir to $ENV::HOME/tls-lab/int1 and update private_key, certificate, and crl to the int1 filenames.
Generate the intermediate key:
openssl genrsa -out "$HOME/tls-lab/int1/private/int1.key.pem" 2048Restrict the intermediate key the same way as the root key:
chmod 400 "$HOME/tls-lab/int1/private/int1.key.pem"Create the signing request. If the CSR fields are unfamiliar, see things to consider when creating a CSR:
openssl req -new -sha256 -config "$HOME/tls-lab/int1/openssl.cnf" -key "$HOME/tls-lab/int1/private/int1.key.pem" -out "$HOME/tls-lab/int1/csr/int1.csr.pem" -subj "/C=IN/ST=Karnataka/L=Bengaluru/O=GoLinuxCloud/OU=Lab/CN=GoLinuxCloud Intermediate CA 1"Sign it with the root's configuration file. The -extensions flag applies the CA extension block from the root config:
openssl ca -batch -notext -md sha256 -days 3650 -config "$HOME/tls-lab/root/openssl.cnf" -extensions v3_intermediate_ca -in "$HOME/tls-lab/int1/csr/int1.csr.pem" -out "$HOME/tls-lab/int1/certs/int1.cert.pem"Using configuration from /home/alice/tls-lab/root/openssl.cnf
Check that the request matches the signature
Signature ok
Certificate Details:
Serial Number: 4096 (0x1000)
Validity
Not Before: Aug 26 03:17:45 2026 GMT
Not After : Aug 23 03:17:45 2036 GMT
Subject:
countryName = IN
stateOrProvinceName = Karnataka
localityName = Bengaluru
organizationName = GoLinuxCloud
organizationalUnitName = Lab
commonName = GoLinuxCloud Intermediate CA 1
X509v3 extensions:
X509v3 Subject Key Identifier:
2B:09:AD:59:6D:5E:4D:DC:EF:04:EE:3C:80:8C:84:FD:41:86:BD:33
X509v3 Authority Key Identifier:
E8:A0:03:59:27:46:22:23:70:BB:78:A7:C0:BB:26:1C:8F:B2:CD:4B
X509v3 Basic Constraints: critical
CA:TRUE, pathlen:0
X509v3 Key Usage: critical
Digital Signature, Certificate Sign, CRL Sign
Certificate is to be certified until Aug 23 03:17:45 2036 GMT (3650 days)
Write out database with 1 new entries
Database updatedThe configuration path in that message reflects your dir setting ($ENV::HOME/tls-lab/root expands to /home/alice/tls-lab/root for user alice). Database updated means the root recorded the issuance in index.txt:
cat "$HOME/tls-lab/root/index.txt"V 360823031745Z 1000 unknown /C=IN/ST=Karnataka/L=Bengaluru/O=GoLinuxCloud/OU=Lab/CN=GoLinuxCloud Intermediate CA 1OpenSSL stores one line per certificate with columns: status (V valid, R revoked), expiry, revocation date, serial, file name, and distinguished name. The fifth field is often unknown when no filename was recorded for that row.
Create and sign the server certificate with a subjectAltName
Server certificates for public TLS should list DNS names in subjectAltName. OpenSSL's -verify_hostname checks SAN entries first and can fall back to the subject common name, but placing hostnames only in the CN is brittle. Include every name clients will use in the SAN.
Generate the server key:
openssl genrsa -out "$HOME/tls-lab/www.example.com.key.pem" 2048Create the request for the hostname the server will present:
openssl req -new -sha256 -config "$HOME/tls-lab/int1/openssl.cnf" -key "$HOME/tls-lab/www.example.com.key.pem" -out "$HOME/tls-lab/www.example.com.csr.pem" -subj "/C=IN/ST=Karnataka/L=Bengaluru/O=GoLinuxCloud/OU=Lab/CN=www.example.com"Keep the SAN in a separate extension file rather than in the CA's permanent configuration:
printf '[ server_cert ]\nsubjectKeyIdentifier = hash\nauthorityKeyIdentifier = keyid,issuer\nbasicConstraints = critical, CA:false\nkeyUsage = critical, digitalSignature, keyEncipherment\nextendedKeyUsage = serverAuth\nsubjectAltName = DNS:www.example.com\n' > "$HOME/tls-lab/server_cert.ext"Sign the request with the intermediate, pulling extensions from that file:
openssl ca -batch -notext -md sha256 -days 375 -config "$HOME/tls-lab/int1/openssl.cnf" -extfile "$HOME/tls-lab/server_cert.ext" -extensions server_cert -in "$HOME/tls-lab/www.example.com.csr.pem" -out "$HOME/tls-lab/www.example.com.cert.pem"Check the SAN and basicConstraints:
openssl x509 -in "$HOME/tls-lab/www.example.com.cert.pem" -noout -ext subjectAltName,basicConstraintsX509v3 Basic Constraints: critical
CA:FALSE
X509v3 Subject Alternative Name:
DNS:www.example.comCA:FALSE tells path validators not to treat this certificate as a CA when building a chain, even though the private key can still produce signatures cryptographically. For wildcard and multi-name certificates, see creating a SAN certificate.
Assemble ca-chain.pem and fullchain.pem
The intermediate-only bundle is what you pass to -untrusted during verification and what many servers send after the leaf:
cat "$HOME/tls-lab/int1/certs/int1.cert.pem" > "$HOME/tls-lab/ca-chain.pem"The full chain file places the leaf first, then the intermediates:
cat "$HOME/tls-lab/www.example.com.cert.pem" "$HOME/tls-lab/ca-chain.pem" > "$HOME/tls-lab/fullchain.pem"With more than one intermediate level, append to ca-chain.pem in the same order: the leaf's issuer first, then upward toward the root (int2 before int1).
Verify the certificate chain
Put the root in -CAfile as the trust anchor and intermediates in -untrusted:
openssl verify -CAfile "$HOME/tls-lab/root/certs/root.cert.pem" -untrusted "$HOME/tls-lab/ca-chain.pem" "$HOME/tls-lab/www.example.com.cert.pem"/home/alice/tls-lab/www.example.com.cert.pem: OKOK means every signature checked out and every basicConstraints value allowed the path. List the certificates in fullchain.pem to confirm order:
openssl crl2pkcs7 -nocrl -certfile "$HOME/tls-lab/fullchain.pem" | openssl pkcs7 -print_certs -nooutsubject=C=IN, ST=Karnataka, L=Bengaluru, O=GoLinuxCloud, OU=Lab, CN=www.example.com
issuer=C=IN, ST=Karnataka, L=Bengaluru, O=GoLinuxCloud, OU=Lab, CN=GoLinuxCloud Intermediate CA 1
subject=C=IN, ST=Karnataka, L=Bengaluru, O=GoLinuxCloud, OU=Lab, CN=GoLinuxCloud Intermediate CA 1
issuer=C=IN, ST=Karnataka, L=Bengaluru, O=GoLinuxCloud, OU=Lab, CN=GoLinuxCloud Root CAEach subject matches the next entry's issuer.
A quick TLS handshake confirms the server presents the chain correctly. Start the OpenSSL test server with the leaf and intermediates separately:
openssl s_server -accept 14501 -cert "$HOME/tls-lab/www.example.com.cert.pem" -cert_chain "$HOME/tls-lab/ca-chain.pem" -key "$HOME/tls-lab/www.example.com.key.pem" -www -quiet &Without the root in the client trust store, verification stops at the top intermediate:
echo | openssl s_client -connect 127.0.0.1:14501 -servername www.example.com 2>/dev/null | grep 'Verify return code'Verify return code: 20 (unable to get local issuer certificate)Supply the root and the handshake succeeds:
echo | openssl s_client -connect 127.0.0.1:14501 -servername www.example.com -CAfile "$HOME/tls-lab/root/certs/root.cert.pem" 2>/dev/null | grep 'Verify return code'Verify return code: 0 (ok)Shut the test server down when finished:
kill %1s_server with only -cert fullchain.pem and no -cert_chain, it sends the leaf alone. Clients then report unable to get local issuer certificate even when the chain files on disk are correct.
Multiple intermediate CAs and pathlen
One intermediate between root and leaf is enough for most labs. When you need more levels, two design rules matter: each intermediate must be signed by the level above it (not always by the root), and every CA certificate needs the right pathlen in basicConstraints.
Nested chain (correct) Sibling intermediates (wrong nesting)
www.example.com www.example.com
| |
signed by int2 signed by int2
| |
Intermediate CA 2 Intermediate CA 2
| +--------+--------+
signed by int1 | |
| signed by root signed by root
Intermediate CA 1 | |
| Intermediate CA 1 Intermediate CA 2
signed by root |
| signed by root
Root CA |
| Root CAPlan pathlen before you create the root
pathLenConstraint counts how many more CA certificates may appear below this one in a valid path, not counting the leaf. For three intermediate levels (root, int1, int2, int3, leaf):
- Root needs
pathlen:3(int1, int2, int3 below it) - int1 needs
pathlen:2 - int2 needs
pathlen:1 - int3 needs
pathlen:0(issues only leaves)
For N intermediate levels, the root gets pathlen:N and level i gets pathlen:N - i. The value is written by the issuer's configuration at signing time, not the subordinate's own config.
| Intermediate levels | Root | int1 | int2 | int3 |
|---|---|---|---|---|
| 1 | pathlen:1 |
pathlen:0 |
— | — |
| 2 | pathlen:2 |
pathlen:1 |
pathlen:0 |
— |
| 3 | pathlen:3 |
pathlen:2 |
pathlen:1 |
pathlen:0 |
OpenSSL does not enforce the issuer's pathLenConstraint at signing time. A CA with pathlen:0 can still sign another CA certificate; verification fails later with error 25 ... path length constraint exceeded. Decide depth before you create the root, because raising pathlen later means reissuing the root and redistributing it.
Common signing mistakes
Signing an intermediate with openssl x509 -req and no extension file succeeds but leaves no basicConstraints: CA:TRUE. Verification then fails with error 79 ... invalid CA certificate. Check directly:
openssl x509 -in int1.pem -noout -ext basicConstraintsAn empty or missing basicConstraints line means the certificate is not usable as a CA link. Supply extensions with -extfile, or use openssl ca with an extensions section as in the walkthrough above.
openssl x509 -req versus openssl ca
openssl x509 -req |
openssl ca |
|
|---|---|---|
| Configuration file | Not required | Required |
| CA extensions | Via -extfile; CSR extensions may be copied with -copy_extensions |
From an extensions section |
| Issuance database | No database created automatically | Yes, in index.txt |
| Serial tracking | A .srl file, last value only |
Full database with subject and status |
| Revocation / CRL | No index.txt to revoke against |
-revoke and -gencrl when you maintain the CA directory |
| Good for | Throwaway lab certificates | Tutorial workflows that need issuance records |
Use openssl ca in this tutorial when you need an issuance database, revocation, and CRLs. OpenSSL documents it as a minimal sample CA application rather than a full production CA system. See openssl ca versus openssl x509 for a longer comparison.
Script: build a multi-level chain
Repeating the manual steps for several intermediate levels is error-prone, especially the pathlen countdown and signing each level with its parent. The script below takes the number of intermediate levels (1 through 8) and an optional leaf hostname. It creates a CA directory and openssl.cnf per level under $HOME/tls-lab by default.
#!/bin/bash
#
# Build a nested OpenSSL CA chain (up to 8 intermediate levels), with a CA
# database at every level so certificates can be tracked and revoked later.
#
# ./gen-chain.sh 1 root -> int1 -> leaf
# ./gen-chain.sh 3 root -> int1 -> int2 -> int3 -> leaf
#
set -euo pipefail
LEVELS="${1:-}"
LEAF_CN="${2:-www.example.com}"
BASEDIR="${BASEDIR:-$HOME/tls-lab}"
DN_BASE="/C=IN/ST=Karnataka/L=Bengaluru/O=GoLinuxCloud/OU=Lab"
ROOT_DAYS=7300
INT_DAYS=3650
LEAF_DAYS=375
if ! [[ "$LEVELS" =~ ^[1-9][0-9]*$ ]]; then
echo "Usage: $0 <intermediate_levels> [leaf_cn]" >&2
exit 1
fi
if [ "$LEVELS" -gt 8 ]; then
echo "Refusing to build more than 8 intermediate levels." >&2
exit 1
fi
if [ -e "$BASEDIR" ]; then
echo "$BASEDIR already exists. Remove it or set BASEDIR=/other/path." >&2
exit 1
fi
init_ca_dir() {
local dir="$1" slug="$2"
mkdir -p "$dir"/{certs,crl,csr,newcerts,private}
chmod 700 "$dir/private"
touch "$dir/index.txt"
echo 1000 > "$dir/serial"
echo 0100 > "$dir/crlnumber"
cat > "$dir/openssl.cnf" <<EOF
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = $dir
certs = \$dir/certs
crl_dir = \$dir/crl
new_certs_dir = \$dir/newcerts
database = \$dir/index.txt
serial = \$dir/serial
private_key = \$dir/private/$slug.key.pem
certificate = \$dir/certs/$slug.cert.pem
crl = \$dir/crl/$slug.crl.pem
crlnumber = \$dir/crlnumber
crl_extensions = crl_ext
default_crl_days = 30
default_md = sha256
preserve = no
email_in_dn = no
name_opt = ca_default
cert_opt = ca_default
policy = policy_loose
unique_subject = no
copy_extensions = none
[ policy_loose ]
countryName = optional
stateOrProvinceName = optional
localityName = optional
organizationName = optional
organizationalUnitName = optional
commonName = supplied
emailAddress = optional
[ req ]
default_bits = 2048
distinguished_name = req_distinguished_name
string_mask = utf8only
default_md = sha256
prompt = no
[ req_distinguished_name ]
countryName = Country Name
stateOrProvinceName = State
localityName = Locality
0.organizationName = Organization
organizationalUnitName = Organizational Unit
commonName = Common Name
[ crl_ext ]
authorityKeyIdentifier = keyid:always
[ v3_intermediate_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true, pathlen:0
keyUsage = critical, digitalSignature, cRLSign, keyCertSign
EOF
}
set_signing_pathlen() {
sed -i -E "s/pathlen:[A-Za-z0-9_]+/pathlen:$2/" "$1/openssl.cnf"
}
echo "==> Building a chain with $LEVELS intermediate level(s) under $BASEDIR"
ROOTDIR="$BASEDIR/root"
init_ca_dir "$ROOTDIR" "root"
openssl genrsa -out "$ROOTDIR/private/root.key.pem" 4096 2>/dev/null
chmod 400 "$ROOTDIR/private/root.key.pem"
openssl req -new -x509 -sha256 -days "$ROOT_DAYS" \
-config "$ROOTDIR/openssl.cnf" \
-key "$ROOTDIR/private/root.key.pem" \
-out "$ROOTDIR/certs/root.cert.pem" \
-subj "$DN_BASE/CN=GoLinuxCloud Root CA" \
-addext "basicConstraints=critical,CA:true,pathlen:$LEVELS" \
-addext "keyUsage=critical,digitalSignature,cRLSign,keyCertSign" \
-addext "subjectKeyIdentifier=hash"
chmod 444 "$ROOTDIR/certs/root.cert.pem"
echo " root CA ready (pathlen:$LEVELS)"
PARENT_DIR="$ROOTDIR"
PARENT_SLUG="root"
for i in $(seq 1 "$LEVELS"); do
DIR="$BASEDIR/int$i"
SLUG="int$i"
init_ca_dir "$DIR" "$SLUG"
openssl genrsa -out "$DIR/private/$SLUG.key.pem" 2048 2>/dev/null
chmod 400 "$DIR/private/$SLUG.key.pem"
openssl req -new -sha256 \
-config "$DIR/openssl.cnf" \
-key "$DIR/private/$SLUG.key.pem" \
-out "$DIR/csr/$SLUG.csr.pem" \
-subj "$DN_BASE/CN=GoLinuxCloud Intermediate CA $i"
set_signing_pathlen "$PARENT_DIR" "$(( LEVELS - i ))"
openssl ca -batch -notext -md sha256 -days "$INT_DAYS" \
-config "$PARENT_DIR/openssl.cnf" \
-extensions v3_intermediate_ca \
-in "$DIR/csr/$SLUG.csr.pem" \
-out "$DIR/certs/$SLUG.cert.pem" 2>/dev/null
chmod 444 "$DIR/certs/$SLUG.cert.pem"
echo " int$i signed by $PARENT_SLUG (pathlen:$(( LEVELS - i )))"
PARENT_DIR="$DIR"
PARENT_SLUG="$SLUG"
done
LEAFDIR="$BASEDIR/leaf"
mkdir -p "$LEAFDIR"
chmod 700 "$LEAFDIR"
openssl genrsa -out "$LEAFDIR/$LEAF_CN.key.pem" 2048 2>/dev/null
chmod 400 "$LEAFDIR/$LEAF_CN.key.pem"
openssl req -new -sha256 \
-config "$PARENT_DIR/openssl.cnf" \
-key "$LEAFDIR/$LEAF_CN.key.pem" \
-out "$LEAFDIR/$LEAF_CN.csr.pem" \
-subj "$DN_BASE/CN=$LEAF_CN"
cat > "$LEAFDIR/server_cert.ext" <<EOF
[ server_cert ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
basicConstraints = critical, CA:false
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = DNS:$LEAF_CN
EOF
openssl ca -batch -notext -md sha256 -days "$LEAF_DAYS" \
-config "$PARENT_DIR/openssl.cnf" \
-extfile "$LEAFDIR/server_cert.ext" -extensions server_cert \
-in "$LEAFDIR/$LEAF_CN.csr.pem" \
-out "$LEAFDIR/$LEAF_CN.cert.pem" 2>/dev/null
chmod 444 "$LEAFDIR/$LEAF_CN.cert.pem"
echo " leaf $LEAF_CN signed by $PARENT_SLUG"
CHAIN="$BASEDIR/ca-chain.pem"
: > "$CHAIN"
for i in $(seq "$LEVELS" -1 1); do
cat "$BASEDIR/int$i/certs/int$i.cert.pem" >> "$CHAIN"
done
cat "$LEAFDIR/$LEAF_CN.cert.pem" "$CHAIN" > "$BASEDIR/fullchain.pem"
echo "==> Verifying"
openssl verify -CAfile "$ROOTDIR/certs/root.cert.pem" -untrusted "$CHAIN" \
"$LEAFDIR/$LEAF_CN.cert.pem"
echo
echo "Root CA : $ROOTDIR/certs/root.cert.pem"
echo "Intermediates: $CHAIN"
echo "Server chain : $BASEDIR/fullchain.pem"
echo "Server key : $LEAFDIR/$LEAF_CN.key.pem"Save it as gen-chain.sh, make it executable, and run with the depth you want:
chmod +x gen-chain.shRun the script with a custom base directory so it does not collide with the manual walkthrough:
BASEDIR="$HOME/chain2" ./gen-chain.sh 2==> Building a chain with 2 intermediate level(s) under /home/alice/chain2
root CA ready (pathlen:2)
int1 signed by root (pathlen:1)
int2 signed by int1 (pathlen:0)
leaf www.example.com signed by int2
==> Verifying
/home/alice/chain2/leaf/www.example.com.cert.pem: OK
Root CA : /home/alice/chain2/root/certs/root.cert.pem
Intermediates: /home/alice/chain2/ca-chain.pem
Server chain : /home/alice/chain2/fullchain.pem
Server key : /home/alice/chain2/leaf/www.example.com.key.pemFor revocation and CRL publication after you have a working CA directory, see revoke certificate and generate CRL.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
error 79 ... invalid CA certificate |
Issuing certificate has no basicConstraints marking it a CA, usually from openssl x509 -req with no -extfile |
Reissue with basicConstraints = critical, CA:true via -extfile or an openssl ca extensions section |
error 20 ... unable to get local issuer certificate |
Trust anchor missing on the client, or the server is not sending intermediates | Pass the root with -CAfile; give the server its intermediates through -cert_chain or a leaf-plus-intermediates bundle |
error 25 ... path length constraint exceeded |
Chain is deeper than a pathlen value permits |
Recalculate as root pathlen:N and level i pathlen:N - i, then reissue affected CA certificates |
error 62 ... hostname mismatch |
subjectAltName missing or does not include the requested name |
Reissue the leaf with every hostname in subjectAltName |
variable has no value from openssl ca |
Config references an unset environment variable such as $ENV::SAN |
Move request-specific values into -extfile; keep dir = $ENV::HOME/... for paths |
unable to load CA private key |
Wrong private_key path in the config, or key permissions exclude the current user |
Fix the private_key line under $ENV::HOME/tls-lab/... and confirm the key is readable |
The mandatory commonName field was missing |
Subject has no CN and the policy section requires one | Add a CN to -subj, or relax the policy section |
TXT_DB error number 2 |
unique_subject is enabled and a certificate with the same subject already exists |
Set unique_subject = no in the config, or revoke the earlier certificate first |
References
- openssl-ca (CA command, database files, and policy sections)
- openssl-x509 (certificate display and signing with
-extfile) - x509v3_config (
basicConstraints,keyUsage, andsubjectAltNamesyntax) - openssl-verify (verification options and error code meanings)
- RFC 5280 (X.509 certificate and CRL profile, including
pathLenConstraint)
Summary
An OpenSSL certificate chain is root CA, intermediate CA(s), and leaf certificate, assembled into ca-chain.pem and fullchain.pem, then validated with openssl verify and optionally a TLS handshake. The manual walkthrough above builds one intermediate level under $HOME/tls-lab using openssl ca, with dir = $ENV::HOME/tls-lab/... in each configuration file so any normal user account can run it.
When something fails, the signing step often succeeded already. error 79 usually means a certificate in the path was never marked as a CA. error 25 means the chain is deeper than pathlen allows. error 20 often means the server did not send intermediates or the client lacks the root trust anchor. Run openssl verify after each signing step, not only at the end.
For multiple intermediate levels, set pathlen before creating the root and sign each level with its parent, not always with the root. The script automates that for up to eight levels. Use openssl ca when you need an issuance database; OpenSSL documents it as a sample CA tool, not a production CA platform. Revocation and CRL steps live in revoke certificate and generate CRL. For mutual TLS certificate issuance from your intermediate, see creating client and server certificates.
Frequently Asked Questions
How many intermediate CAs should a certificate chain have?
One intermediate level covers almost every real deployment, because it lets you keep the root key offline while an online intermediate does the day-to-day signing. Extra levels are useful when you need to delegate signing to separate teams, regions, or environments, each with its own revocable CA. Every extra level adds a certificate that clients must receive and validate, so depth costs handshake bytes and adds another key you have to protect.
What does pathlen mean in basicConstraints?
The pathLenConstraint value states how many more CA certificates may appear below this one in a valid path, not counting the end-entity certificate. A CA with pathlen:0 can only issue leaf certificates, while pathlen:1 can issue one more subordinate CA that then issues leaves. If you plan a chain with three intermediate levels, the root needs pathlen:3 and each level below it counts down to zero.
Why does openssl verify report invalid CA certificate?
The certificate you are using as an issuer has no basicConstraints extension marking it as a CA, so OpenSSL refuses to treat it as one. This happens most often when a certificate was signed with openssl x509 -req and no extension file was supplied, because that command copies nothing from the request and adds no CA extensions of its own. Sign the subordinate again and pass the CA extensions through -extfile, or use openssl ca with an extensions section.
Should the root certificate be included in the chain a server sends?
No. A TLS server should present its leaf certificate followed by the intermediate certificates, and stop there. The root is the trust anchor and has to already be in the client trust store, so sending it wastes handshake bytes and proves nothing, because a client that does not already trust the root will not start trusting it just because the server offered a copy.
Can I revoke a certificate signed with openssl x509 -req?
Not directly, because that command keeps no record of what it issued. Revocation works from the CA database that openssl ca maintains in index.txt, which is where the serial number, subject, and status of every issued certificate live. If you expect to revoke certificates or publish a CRL, issue them with openssl ca against a proper CA directory from the start.
Why does my server only send the leaf certificate?
Most TLS servers read the chain from a separate option or from a bundle file that contains the leaf followed by the intermediates. With the OpenSSL test server, the -cert option loads only the first certificate, so the intermediates have to come from -cert_chain. Clients then fail with unable to get local issuer certificate even though the chain itself is perfectly valid.

