OpenSSL Create Certificate Chain: Root, Intermediate and Leaf

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
Short on time?Beta Get the gist of this 21-minute read in seconds, then ask follow-up questions

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.

text
  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 into fullchain.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:

text
leaf
  ↓
int2
  ↓
int1
  ↓
root

ca-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.

OpenSSL create certificate chain with Root and Intermediate CA

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):

bash
cat intermediate.pem > ca-chain.pem

Add the leaf in front to build what many servers expect:

bash
cat leaf.pem ca-chain.pem > fullchain.pem

Validate the leaf against the root, passing intermediates as untrusted links in the path:

bash
openssl verify -CAfile root.pem -untrusted ca-chain.pem leaf.pem
output
leaf.pem: OK

With 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:

bash
mkdir -p "$HOME/tls-lab/root"/{certs,crl,csr,newcerts,private}

Lock down the private key directory:

bash
chmod 700 "$HOME/tls-lab/root/private"

index.txt is the issuance database. OpenSSL requires the file to exist even when empty:

bash
touch "$HOME/tls-lab/root/index.txt"

The serial file holds the next serial number in hexadecimal (at least two digits):

bash
echo 1000 > "$HOME/tls-lab/root/serial"

The crlnumber file tracks CRL generations if you publish revocation lists later:

bash
echo 0100 > "$HOME/tls-lab/root/crlnumber"

Each directory and file has a distinct role:

  • certs — CA certificate and copies of issued certificates
  • csr — signing requests waiting to be processed
  • newcerts — backup of every issued certificate, named by serial number
  • private — CA private key (not world-readable)
  • crl — published revocation lists
  • index.txt, serial, crlnumber — state that turns a key pair into an operable CA
IMPORTANT
Everything a CA produces is public except the private key in 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:

text
[ 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, keyCertSign

Three settings in that file change behaviour in ways that are easy to miss:

  • copy_extensions = none ignores CSR extensions. With copy, extensions from the request are copied only when the CA profile did not already supply them. copyall is more dangerous because request extensions can replace CA profile values. If you use copy, define basicConstraints = CA:FALSE and appropriate keyUsage in the CA profile so a request cannot upgrade itself to a CA certificate.
  • policy_loose only 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:0 in [ v3_intermediate_ca ] is stamped into the next subordinate this CA signs. This walkthrough uses one intermediate that issues only leaf certificates.
NOTE
Avoid request-specific environment variables such as $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:

bash
openssl genrsa -out "$HOME/tls-lab/root/private/root.key.pem" 4096

Remove all access except for the owner:

bash
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:

bash
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):

bash
openssl x509 -in "$HOME/tls-lab/root/certs/root.cert.pem" -noout -ext basicConstraints
output
X509v3 Basic Constraints: critical
    CA:TRUE, pathlen:1

critical 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:

bash
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:

bash
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:

bash
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:

bash
openssl genrsa -out "$HOME/tls-lab/int1/private/int1.key.pem" 2048

Restrict the intermediate key the same way as the root key:

bash
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:

bash
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:

bash
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"
output
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 updated

The 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:

bash
cat "$HOME/tls-lab/root/index.txt"
output
V	360823031745Z		1000	unknown	/C=IN/ST=Karnataka/L=Bengaluru/O=GoLinuxCloud/OU=Lab/CN=GoLinuxCloud Intermediate CA 1

OpenSSL 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:

bash
openssl genrsa -out "$HOME/tls-lab/www.example.com.key.pem" 2048

Create the request for the hostname the server will present:

bash
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:

bash
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:

bash
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:

bash
openssl x509 -in "$HOME/tls-lab/www.example.com.cert.pem" -noout -ext subjectAltName,basicConstraints
output
X509v3 Basic Constraints: critical
    CA:FALSE
X509v3 Subject Alternative Name: 
    DNS:www.example.com

CA: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:

bash
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:

bash
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:

bash
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"
output
/home/alice/tls-lab/www.example.com.cert.pem: OK

OK means every signature checked out and every basicConstraints value allowed the path. List the certificates in fullchain.pem to confirm order:

bash
openssl crl2pkcs7 -nocrl -certfile "$HOME/tls-lab/fullchain.pem" | openssl pkcs7 -print_certs -noout
output
subject=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 CA

Each 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:

bash
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:

bash
echo | openssl s_client -connect 127.0.0.1:14501 -servername www.example.com 2>/dev/null | grep 'Verify return code'
output
Verify return code: 20 (unable to get local issuer certificate)

Supply the root and the handshake succeeds:

bash
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'
output
Verify return code: 0 (ok)

Shut the test server down when finished:

bash
kill %1
NOTE
If you start s_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.

text
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 CA

Plan 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:

bash
openssl x509 -in int1.pem -noout -ext basicConstraints

An 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.

bash
#!/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:

bash
chmod +x gen-chain.sh

Run the script with a custom base directory so it does not collide with the manual walkthrough:

bash
BASEDIR="$HOME/chain2" ./gen-chain.sh 2
output
==> 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.pem

For 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, and subjectAltName syntax)
  • 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.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)