Build a PKI from Scratch with OpenSSL
Create a two-tier Certificate Authority and issue TLS certificates locally.
Introduction
30 Second Summary
Every time you see a padlock icon in your browser, a chain of digital trust is working behind the scenes. A hierarchy of certificate authorities vouches for the website's identity, and if any link in that chain breaks, the connection fails.
In this project, you will build a complete two-tier Public Key Infrastructure on your local machine using OpenSSL. You will create your own Certificate Authorities, issue TLS server certificates, verify the full chain of trust, and practice certificate revocation.
What You'll Build
A complete two-tier PKI hierarchy with a Root CA and Intermediate CA, capable of issuing, verifying, and revoking TLS server certificates using X.509 standards.
By the end of this project, you'll have:
- A Root Certificate Authority and Intermediate CA that mirror how real-world certificate authorities operate.
- A TLS server certificate signed by your own certificate chain, with full end-to-end verification.
- A Certificate Revocation List that proves you can invalidate compromised certificates.
- Secret Mission: Set up a local HTTPS server using your certificates and verify the chain with curl.
Are there any prerequisites?
You only need basic familiarity with the command line (navigating directories, running commands). No prior cryptography or security knowledge is required.
Install and Verify OpenSSL
To build a Public Key Infrastructure, you need a cryptography toolkit that can generate keys, create certificates, and sign requests.
OpenSSL is the industry-standard tool for these operations. macOS ships with LibreSSL instead of OpenSSL, so you will install the real OpenSSL 3.x from Homebrew and set up the project directory structure that both Certificate Authorities will use.
In this step, get ready to:
- Install OpenSSL 3.6.x via Homebrew.
- Set up a convenience alias and create the project directory.
- Build the full directory structure for both Certificate Authorities.
Install OpenSSL via Homebrew
macOS includes LibreSSL at /usr/bin/openssl, but it lacks features needed for full PKI operations. You need OpenSSL 3.x from Homebrew.
- Install OpenSSL by running this command:
brew install openssl@3
What does this command do?
Homebrew downloads and installs the openssl@3 formula. This gives you OpenSSL 3.6.2 (the current stable release) without replacing the system LibreSSL.
If you already have it installed, Homebrew will tell you so. The command is safe to run again.
- Verify the installation by running this command:
$(brew --prefix openssl@3)/bin/openssl version
Why the full path?
Homebrew does not override the system openssl binary. Running just openssl version would invoke LibreSSL instead. The full Homebrew path ensures you are using the real OpenSSL.
Check your terminal output against the options below.
✔️ I see OpenSSL 3.6.x
Your output looks something like OpenSSL 3.6.2 7 Apr 2026. OpenSSL is ready to go.
ⓧ I see an older OpenSSL version
If you see a version lower than 3.6, your Homebrew formula index is outdated.
- Update Homebrew and upgrade OpenSSL by running:
brew update && brew upgrade openssl@3
- Run the version check again:
$(brew --prefix openssl@3)/bin/openssl version
You should now see 3.6.x in the output.
ⓧ I see an error
If you see a message like "No available formula" or "command not found", Homebrew may not be installed or configured correctly.
- Check that Homebrew is installed by running:
brew --version
If this also fails, install Homebrew by following the instructions at https://brew.sh.
- After Homebrew is working, run the install command again:
brew install openssl@3
- Then verify with:
$(brew --prefix openssl@3)/bin/openssl version
Create an alias and set up the project directory
Typing the full Homebrew path every time is tedious. A shell alias lets you type just openssl and have it point to the correct binary for this terminal session.
- Create the alias by running this command:
alias openssl=$(brew --prefix openssl@3)/bin/openssl
How long does this alias last?
This alias only lasts for your current terminal session. If you close and reopen your terminal, you will need to run this alias command again before continuing.
- Confirm the alias works by running:
openssl version
You should see the same OpenSSL 3.6.x output as before, but this time from the short command.
- Create the project directory and move into it by running these commands:
mkdir -p ~/pki-lab && cd ~/pki-lab
- Confirm you are in the right directory by running:
pwd
You should see /Users/<your-username>/pki-lab in the output.
Still in your home directory?
If pwd does not show pki-lab at the end of the path, the && in the command may have failed. Run cd ~/pki-lab separately to move into the directory.
Help me get into my pki-lab directory
Create the full directory structure for both CAs
A Certificate Authority needs a specific set of directories and files to operate. Each CA tracks issued certificates in a database file, assigns serial numbers, and stores keys separately from certificates.
You will create a setup script that builds this entire structure in one run. Both the Root CA and Intermediate CA get their own directory tree.
- Create the setup script by running this command:
touch setup.sh
- Open setup.sh in a text editor. You can use nano directly in the terminal:
nano setup.sh
- Paste the following script into the editor:
#!/bin/bash
# PKI Lab - Directory Structure Setup Script
set -e
echo "=== Creating PKI Lab Directory Structure ==="
# Create Root CA directories
mkdir -p root-ca/{certs,crl,newcerts,private}
chmod 700 root-ca/private
touch root-ca/index.txt
echo 1000 > root-ca/serial
echo 1000 > root-ca/crlnumber
# Create Intermediate CA directories
mkdir -p intermediate-ca/{certs,crl,newcerts,private}
chmod 700 intermediate-ca/private
touch intermediate-ca/index.txt
echo 2000 > intermediate-ca/serial
echo 2000 > intermediate-ca/crlnumber
echo "=== Directory structure created successfully ==="
echo ""
echo "Directory layout:"
echo "pki-lab/"
echo "├── root-ca/"
echo "│ ├── certs/"
echo "│ ├── crl/"
echo "│ ├── newcerts/"
echo "│ ├── private/"
echo "│ ├── index.txt"
echo "│ ├── serial"
echo "│ ├── crlnumber"
echo "│ └── openssl.cnf"
echo "├── intermediate-ca/"
echo "│ ├── certs/"
echo "│ ├── crl/"
echo "│ ├── newcerts/"
echo "│ ├── private/"
echo "│ ├── index.txt"
echo "│ ├── serial"
echo "│ ├── crlnumber"
echo "│ └── openssl.cnf"
echo "└── setup.sh"
What does this script do?
The script creates two parallel directory trees. Each CA gets:
- private/ for secret keys (locked down with chmod 700 so only you can access them).
- certs/ for issued certificates.
- newcerts/ where OpenSSL automatically stores copies of every certificate it signs.
- crl/ for Certificate Revocation Lists.
- index.txt as the certificate database (starts empty).
- serial and crlnumber to track the next certificate and CRL serial numbers.
The Root CA starts serial numbering at 1000 and the Intermediate CA starts at 2000. This makes it easy to tell which CA issued a certificate just by looking at its serial number.
- Save the file in nano by pressing Ctrl+O, then Enter, then Ctrl+X to exit.
- Make the script executable and run it:
chmod +x setup.sh
./setup.sh
You should see the directory layout printed in your terminal, confirming that all directories and files were created.
- Verify the structure by running:
ls root-ca
You should see certs, crl, crlnumber, index.txt, newcerts, private, and serial listed.
Seeing "Permission denied" or missing directories?
Make sure you ran chmod +x setup.sh before ./setup.sh. Without the execute permission, the script cannot run.
Also confirm you are inside ~/pki-lab by running pwd. The script uses relative paths, so it must be executed from the project root.
Help me troubleshoot my setup script
✔️ Awesome, I've got everything!
Great. Double check you have saved setup.sh and that both root-ca/ and intermediate-ca/ directories are present.
ⓧ I'd like to double check the full code
Here is the complete setup.sh script for comparison:
#!/bin/bash
# PKI Lab - Directory Structure Setup Script
set -e
echo "=== Creating PKI Lab Directory Structure ==="
# Create Root CA directories
mkdir -p root-ca/{certs,crl,newcerts,private}
chmod 700 root-ca/private
touch root-ca/index.txt
echo 1000 > root-ca/serial
echo 1000 > root-ca/crlnumber
# Create Intermediate CA directories
mkdir -p intermediate-ca/{certs,crl,newcerts,private}
chmod 700 intermediate-ca/private
touch intermediate-ca/index.txt
echo 2000 > intermediate-ca/serial
echo 2000 > intermediate-ca/crlnumber
echo "=== Directory structure created successfully ==="
echo ""
echo "Directory layout:"
echo "pki-lab/"
echo "├── root-ca/"
echo "│ ├── certs/"
echo "│ ├── crl/"
echo "│ ├── newcerts/"
echo "│ ├── private/"
echo "│ ├── index.txt"
echo "│ ├── serial"
echo "│ ├── crlnumber"
echo "│ └── openssl.cnf"
echo "├── intermediate-ca/"
echo "│ ├── certs/"
echo "│ ├── crl/"
echo "│ ├── newcerts/"
echo "│ ├── private/"
echo "│ ├── index.txt"
echo "│ ├── serial"
echo "│ ├── crlnumber"
echo "│ └── openssl.cnf"
echo "└── setup.sh"
Your workspace is ready. Next up, you will create the Root Certificate Authority that sits at the top of your trust hierarchy.
Create the Root Certificate Authority
Every certificate chain starts with a single trusted root. The Root Certificate Authority is the top of the trust hierarchy. It signs its own certificate (self-signed) and then signs the certificates of Intermediate CAs below it.
In this step, you will create a configuration file that defines how your Root CA operates, generate its cryptographic key pair, and issue its self-signed certificate. This is the foundation that everything else in your PKI will trust.
In this step, get ready to:
- Create the Root CA configuration file that defines policies and extensions.
- Generate an RSA 4096-bit private key for the Root CA.
- Issue the self-signed Root CA certificate and verify its contents.
Create the Root CA configuration file
OpenSSL's openssl ca command relies on a configuration file to know where keys, certificates, and databases live. This file also defines the policies that control which certificate requests the CA will accept.
- Create a new file called root-ca/openssl.cnf in your text editor and paste in the following configuration:
# OpenSSL Root CA configuration file
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = ./root-ca
certs = $dir/certs
crl_dir = $dir/crl
new_certs_dir = $dir/newcerts
database = $dir/index.txt
serial = $dir/serial
crlnumber = $dir/crlnumber
private_key = $dir/private/root-ca.key
certificate = $dir/certs/root-ca.crt
crl = $dir/crl/root-ca.crl
default_crl_days = 30
default_md = sha256
default_days = 3650
name_opt = ca_default
cert_opt = ca_default
preserve = no
policy = policy_strict
[ policy_strict ]
countryName = match
stateOrProvinceName = match
organizationName = match
organizationalUnitName = optional
commonName = supplied
emailAddress = optional
[ req ]
default_bits = 4096
distinguished_name = req_distinguished_name
string_mask = utf8only
default_md = sha256
x509_extensions = v3_ca
prompt = no
[ req_distinguished_name ]
C = US
ST = California
O = PKI Lab
CN = PKI Lab Root CA
[ v3_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:TRUE
keyUsage = critical, digitalSignature, cRLSign, keyCertSign
[ v3_intermediate_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, digitalSignature, cRLSign, keyCertSign
What does this configuration do?
The [ CA_default ] section tells OpenSSL where to find the CA's files (keys, certificates, serial numbers, database). All paths are relative to where you run commands, so you must always run from ~/pki-lab.
The [ policy_strict ] section requires that any certificate request must match the Root CA's country, state, and organization. This is how real Root CAs restrict what they sign.
The [ v3_ca ] section defines the X.509 v3 extensions for the root certificate itself: CA:TRUE marks it as a Certificate Authority, and keyUsage restricts it to signing certificates and CRLs only.
The [ v3_intermediate_ca ] section will be used later when signing the Intermediate CA. The pathlen:0 constraint means the Intermediate CA cannot sign other CAs below it.
💡 Why use a configuration file instead of command-line flags?
OpenSSL's command-line interface has dozens of flags for CA operations. A configuration file captures all of these settings in one place, making your CA reproducible and auditable. The OpenSSL PKI Tutorial recommends one config per CA as the key to taming the CLI.
Real-world CAs like Let's Encrypt and enterprise CAs follow this exact pattern. Every setting is documented in a versioned config file rather than scattered across shell scripts.
- Save root-ca/openssl.cnf.
- Confirm the file exists by running:
ls root-ca/openssl.cnf
You should see root-ca/openssl.cnf printed in the output.
File not found?
Make sure you saved the file in the correct location. You should be in the ~/pki-lab directory, and the file should be at root-ca/openssl.cnf relative to that directory.
Help me find where I saved my openssl.cnf file
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete root-ca/openssl.cnf file for comparison:
# OpenSSL Root CA configuration file
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = ./root-ca
certs = $dir/certs
crl_dir = $dir/crl
new_certs_dir = $dir/newcerts
database = $dir/index.txt
serial = $dir/serial
crlnumber = $dir/crlnumber
private_key = $dir/private/root-ca.key
certificate = $dir/certs/root-ca.crt
crl = $dir/crl/root-ca.crl
default_crl_days = 30
default_md = sha256
default_days = 3650
name_opt = ca_default
cert_opt = ca_default
preserve = no
policy = policy_strict
[ policy_strict ]
countryName = match
stateOrProvinceName = match
organizationName = match
organizationalUnitName = optional
commonName = supplied
emailAddress = optional
[ req ]
default_bits = 4096
distinguished_name = req_distinguished_name
string_mask = utf8only
default_md = sha256
x509_extensions = v3_ca
prompt = no
[ req_distinguished_name ]
C = US
ST = California
O = PKI Lab
CN = PKI Lab Root CA
[ v3_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:TRUE
keyUsage = critical, digitalSignature, cRLSign, keyCertSign
[ v3_intermediate_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, digitalSignature, cRLSign, keyCertSign
Generate the Root CA private key and self-signed certificate
The Root CA needs a cryptographic key pair. The private key is the most sensitive asset in your entire PKI. If it is compromised, every certificate in the chain becomes untrustworthy.
- Generate a 4096-bit RSA private key encrypted with AES-256-CBC by running this command:
openssl genpkey -algorithm RSA -out root-ca/private/root-ca.key -aes-256-cbc -pkeyopt rsa_keygen_bits:4096
What will this command ask me?
Because -aes-256-cbc encrypts the key, OpenSSL will prompt you to enter a PEM passphrase. Choose something memorable. You will need this passphrase every time you use the Root CA key to sign certificates.
Type your passphrase, press Enter, then type it again to confirm. The characters will not appear on screen as you type.
- Lock down the key's file permissions by running:
chmod 400 root-ca/private/root-ca.key
Why chmod 400?
The 400 permission means only the file owner can read the key, and nobody can write or execute it. In real-world CAs, root keys are stored on air-gapped hardware security modules. Restricting file permissions is the local equivalent of that physical security.
- Confirm the key file exists and has the correct permissions by running:
ls -la root-ca/private/root-ca.key
You should see -r-------- at the start of the line, confirming read-only access for the owner only.
Seeing a permission denied error?
If the genpkey command fails with a permission error, make sure the root-ca/private/ directory exists and you have write access to it. Run ls -la root-ca/ to check.
If you mistyped the passphrase confirmation, the key file may not have been created. Run the genpkey command again.
Help me fix my Root CA key generation error
Now you can use this key to create the self-signed Root CA certificate.
- Generate the self-signed Root CA certificate by running:
openssl req -config root-ca/openssl.cnf -key root-ca/private/root-ca.key -new -x509 -days 3650 -sha256 -extensions v3_ca -out root-ca/certs/root-ca.crt
What does this command do?
- -config root-ca/openssl.cnf tells OpenSSL to read your configuration file for the distinguished name and extensions.
- -key root-ca/private/root-ca.key signs the certificate with the private key you just generated.
- -new -x509 creates a new self-signed certificate (not a certificate request).
- -days 3650 sets the certificate validity to 10 years. Root CAs have long lifetimes because replacing them means updating every client that trusts them.
- -sha256 uses the SHA-256 digest algorithm for the certificate signature.
- -extensions v3_ca applies the X.509 v3 extensions from the [ v3_ca ] section of your config.
OpenSSL will prompt you for the private key passphrase you set earlier. Enter it to sign the certificate.
- Confirm the certificate was created by running:
ls root-ca/certs/root-ca.crt
You should see root-ca/certs/root-ca.crt in the output.
Certificate file not created?
If you entered the wrong passphrase, OpenSSL will print an error and no certificate file is created. Run the openssl req command again and enter the correct passphrase.
If you see an error about missing config sections, double check that root-ca/openssl.cnf contains the [ v3_ca ] section with no typos in the section header.
Help me fix my self-signed certificate error
Verify the Root CA certificate
A certificate is only as good as its contents. You need to confirm your Root CA certificate has the correct identity and extensions before trusting it to sign anything.
- Examine the Root CA certificate details by running:
openssl x509 -noout -text -in root-ca/certs/root-ca.crt
What should I look for in the output?
This prints the full human-readable certificate details. Confirm these three things:
- Issuer and Subject are identical (both show C = US, ST = California, O = PKI Lab, CN = PKI Lab Root CA). This proves the certificate is self-signed.
- Basic Constraints shows CA:TRUE. This marks the certificate as a Certificate Authority.
- Key Usage includes Certificate Sign and CRL Sign. These are the only operations a Root CA key should perform.
🙋♀️ Output looks different from expected?
If the Issuer or Subject shows different values, your [ req_distinguished_name ] section in root-ca/openssl.cnf may have a typo. Compare it against the configuration file above.
If CA:TRUE is missing, your [ v3_ca ] section may not have been applied. Check that the -extensions v3_ca flag was included in the openssl req command.
Help me fix my Root CA certificate verification output
Your Root CA is ready. It has a strong 4096-bit key and a self-signed certificate with the correct extensions. Next up, you will create the Intermediate CA and have the Root CA sign its certificate.
Create the Intermediate Certificate Authority
Your Root CA is locked away and ready to sign. In a real-world PKI, the Root CA never directly issues certificates to servers or users.
Instead, the Root CA signs an Intermediate CA, and that Intermediate CA handles day-to-day certificate issuance. This limits the blast radius if a key is compromised. In this step, you will create your Intermediate CA, have the Root CA vouch for it, and verify the trust chain.
In this step, get ready to:
- Write the Intermediate CA configuration file with a looser signing policy.
- Generate the Intermediate CA key pair and Certificate Signing Request.
- Sign the Intermediate CA certificate using the Root CA and verify the chain.
Create the Intermediate CA configuration file
The Intermediate CA needs its own OpenSSL configuration file. This file defines the CA's directory layout, its signing policy, and the extensions it will stamp onto server certificates.
Why a looser policy?
The Root CA uses policy_strict which requires the country, state, and organization to match exactly. The Intermediate CA uses policy_loose so it can sign certificates for any organization. This mirrors real life where a public CA like Let's Encrypt issues certificates to anyone who proves domain ownership.
- Create the Intermediate CA configuration file by running this command:
cat > intermediate-ca/openssl.cnf << 'EOF'
# OpenSSL Intermediate CA configuration file
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = ./intermediate-ca
certs = $dir/certs
crl_dir = $dir/crl
new_certs_dir = $dir/newcerts
database = $dir/index.txt
serial = $dir/serial
crlnumber = $dir/crlnumber
private_key = $dir/private/intermediate-ca.key
certificate = $dir/certs/intermediate-ca.crt
crl = $dir/crl/intermediate-ca.crl
default_crl_days = 30
default_md = sha256
default_days = 365
name_opt = ca_default
cert_opt = ca_default
preserve = no
policy = policy_loose
[ policy_loose ]
countryName = optional
stateOrProvinceName = optional
localityName = optional
organizationName = optional
organizationalUnitName = optional
commonName = supplied
emailAddress = optional
[ req ]
default_bits = 4096
distinguished_name = req_distinguished_name
string_mask = utf8only
default_md = sha256
prompt = no
[ req_distinguished_name ]
C = US
ST = California
O = PKI Lab
CN = PKI Lab Intermediate CA
[ server_cert ]
basicConstraints = CA:FALSE
nsCertType = server
nsComment = "OpenSSL Generated Server Certificate"
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer:always
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
EOF
What does this configuration do?
- The [ CA_default ] section points to the Intermediate CA's own directories and files. It sets default_days = 365 so server certificates it issues last one year.
- The [ policy_loose ] section marks every field as optional except commonName which must be supplied. This allows the Intermediate CA to sign certificates from any organization.
- The [ server_cert ] section defines extensions for end-entity server certificates. CA:FALSE prevents a server cert from signing other certificates. extendedKeyUsage = serverAuth restricts it to TLS server authentication.
- Confirm the file was created by running:
cat intermediate-ca/openssl.cnf
You should see the full configuration file printed in your terminal, starting with the comment # OpenSSL Intermediate CA configuration file.
Seeing "No such file or directory"?
Make sure you are running all commands from the ~/pki-lab directory. Run pwd to check your current location. If you are somewhere else, run cd ~/pki-lab to get back.
Also confirm the intermediate-ca/ directory exists by running ls intermediate-ca/. You should see subdirectories like certs/, private/, and newcerts/.
Help me troubleshoot my Intermediate CA config file creation. I'm getting a 'No such file or directory' error.
Generate the key and Certificate Signing Request
Just like the Root CA, the Intermediate CA needs its own RSA key pair. It also needs a Certificate Signing Request (CSR) to send to the Root CA for signing.
- Generate the Intermediate CA private key by running this command:
openssl genpkey -algorithm RSA -out intermediate-ca/private/intermediate-ca.key -aes-256-cbc -pkeyopt rsa_keygen_bits:4096
What does this command do?
- This generates a 4096-bit RSA key encrypted with AES-256-CBC, just like the Root CA key.
- OpenSSL will prompt you to set a passphrase. Choose something memorable. You will need it every time the Intermediate CA signs a certificate.
- Set restrictive permissions on the key file by running:
chmod 400 intermediate-ca/private/intermediate-ca.key
- Verify the key was created by running:
ls -la intermediate-ca/private/
You should see intermediate-ca.key with -r-------- permissions (read-only for the owner).
Seeing a permissions error?
If the chmod command fails, make sure the key file actually exists first. Run ls intermediate-ca/private/ to check. If the key generation failed, re-run the openssl genpkey command above.
Help me fix permissions on my Intermediate CA key file.
Now you need a CSR. This is a formal request that says "I am the Intermediate CA. Please sign my public key." The Root CA will review and sign this request.
- Create the Certificate Signing Request by running this command:
openssl req -config intermediate-ca/openssl.cnf -new -key intermediate-ca/private/intermediate-ca.key -sha256 -out intermediate-ca/certs/intermediate-ca.csr
What does this command do?
- The -config flag tells OpenSSL to read the distinguished name (country, state, org, CN) from your Intermediate CA config file. Because prompt = no is set, it will not ask you for these values interactively.
- The -new flag creates a new certificate request.
- The -key flag points to the Intermediate CA's private key. OpenSSL will ask for the passphrase you set earlier.
- The output is a .csr file containing the public key and identity information, ready to send to the Root CA.
- Confirm the CSR was created by running:
ls intermediate-ca/certs/intermediate-ca.csr
You should see the file path printed back to you, confirming the CSR exists.
Command asking for interactive input?
If OpenSSL prompts you for Country Name, State, etc., your config file may be missing prompt = no in the [ req ] section. Open intermediate-ca/openssl.cnf and check that the [ req ] section includes prompt = no.
Help me fix my Intermediate CA CSR generation. OpenSSL is prompting me for values instead of reading from the config.
Sign the Intermediate CA certificate with the Root CA
The CSR is ready. Now the Root CA will review it and issue a signed certificate, establishing the trust chain between the two CAs.
- Sign the Intermediate CA certificate using the Root CA by running this command:
openssl ca -config root-ca/openssl.cnf -extensions v3_intermediate_ca -days 1825 -notext -md sha256 -in intermediate-ca/certs/intermediate-ca.csr -out intermediate-ca/certs/intermediate-ca.crt
What does this command do?
- The -config root-ca/openssl.cnf flag tells OpenSSL to act as the Root CA (using its config, key, and certificate).
- The -extensions v3_intermediate_ca flag applies the extensions you defined in the Root CA config. These set CA:TRUE and pathlen:0, meaning this CA can sign end-entity certificates but cannot create sub-CAs beneath it.
- The -days 1825 flag gives the Intermediate CA certificate a 5-year validity period (shorter than the Root CA's 10 years).
- The -notext flag omits the human-readable text header from the output certificate file.
OpenSSL will display the certificate details and ask you to confirm the signing. It will prompt you to enter the Root CA passphrase, then ask "Sign the certificate?" and "1 out of 1 certificate requests certified, commit?" Type y and press Enter for both prompts.
💡 Why does pathlen:0 matter?
The pathlen:0 constraint means this Intermediate CA can only sign end-entity certificates (like server or client certs). It cannot create another CA below itself. This is a security boundary that limits how deep the trust chain can grow.
- Verify the chain of trust by running this command:
openssl verify -CAfile root-ca/certs/root-ca.crt intermediate-ca/certs/intermediate-ca.crt
You should see output ending with OK. This confirms the Root CA's signature on the Intermediate CA certificate is valid.
Seeing a verification error?
If verification fails, the most common cause is that the signing step did not complete successfully. Check that intermediate-ca/certs/intermediate-ca.crt exists and is not empty by running ls -la intermediate-ca/certs/intermediate-ca.crt.
If the file is missing or empty, re-run the openssl ca signing command. Make sure you type y at both confirmation prompts.
Help me troubleshoot my Intermediate CA chain verification failure.
✔️ Awesome, I've got everything!
Great. Double check you have both intermediate-ca/openssl.cnf and intermediate-ca/certs/intermediate-ca.crt in place, and that your verify command returned OK.
ⓧ I'd like to double check the full code
Here is the complete intermediate-ca/openssl.cnf file for comparison:
# OpenSSL Intermediate CA configuration file
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = ./intermediate-ca
certs = $dir/certs
crl_dir = $dir/crl
new_certs_dir = $dir/newcerts
database = $dir/index.txt
serial = $dir/serial
crlnumber = $dir/crlnumber
private_key = $dir/private/intermediate-ca.key
certificate = $dir/certs/intermediate-ca.crt
crl = $dir/crl/intermediate-ca.crl
default_crl_days = 30
default_md = sha256
default_days = 365
name_opt = ca_default
cert_opt = ca_default
preserve = no
policy = policy_loose
[ policy_loose ]
countryName = optional
stateOrProvinceName = optional
localityName = optional
organizationName = optional
organizationalUnitName = optional
commonName = supplied
emailAddress = optional
[ req ]
default_bits = 4096
distinguished_name = req_distinguished_name
string_mask = utf8only
default_md = sha256
prompt = no
[ req_distinguished_name ]
C = US
ST = California
O = PKI Lab
CN = PKI Lab Intermediate CA
[ server_cert ]
basicConstraints = CA:FALSE
nsCertType = server
nsComment = "OpenSSL Generated Server Certificate"
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer:always
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
Your Intermediate CA is signed, verified, and ready to issue certificates. Next up, you will use it to issue a TLS server certificate for a domain name and verify the full three-link chain of trust.
Issue a TLS Server Certificate
Your two-tier CA hierarchy is now complete. The Root CA signed the Intermediate CA, establishing the first link in your chain of trust.
Now it is time to put that hierarchy to work. In this step, you will generate a TLS server certificate for a fictional domain, have it signed by the Intermediate CA, and prove the full trust chain from server certificate all the way back to the Root CA.
In this step, get ready to:
- Generate a server private key and Certificate Signing Request with a Subject Alternative Name.
- Sign the server certificate with your Intermediate CA and build the certificate chain file.
- Verify the full chain of trust from server certificate to Root CA.
Generate the server key and CSR
Server certificates work differently from CA certificates. A web server needs to read its private key without human intervention every time it starts up. That means no passphrase encryption on the server key.
- Generate an RSA 2048-bit server private key (with no passphrase) by running this command:
openssl genpkey -algorithm RSA -out server.key -pkeyopt rsa_keygen_bits:2048
Why no passphrase on this key?
CA keys use passphrase encryption because a human is present each time they sign a certificate. Server keys skip encryption because the server process needs to load the key automatically on startup. No human is there to type a passphrase at 3 AM when the server restarts.
- Confirm the key was created by running:
ls server.key
You should see server.key listed in the output.
Next, you will create a Certificate Signing Request (CSR) that asks the Intermediate CA to issue a certificate for your fictional domain. The CSR includes a Subject Alternative Name (SAN) extension, which is how modern browsers identify which domain names a certificate covers.
- Create the server CSR with a SAN for www.example-lab.com by running this command:
openssl req -new -key server.key -sha256 -out server.csr \
-subj "/C=US/ST=California/O=Example Lab/CN=www.example-lab.com" \
-addext "subjectAltName = DNS:www.example-lab.com,DNS:example-lab.com"
What does this command do?
This command packs several pieces of information into the CSR:
- -subj sets the certificate's identity fields (country, state, organization, and common name) without interactive prompts.
- -addext "subjectAltName = ..." embeds a SAN extension listing both www.example-lab.com and the bare domain example-lab.com. Browsers check SAN, not the Common Name, to validate domains.
- -sha256 specifies the digest algorithm for signing the request.
- Confirm the CSR was created by running:
ls server.csr
You should see server.csr listed in the output.
Seeing a "problems making Certificate Request" error?
Double-check that server.key exists in your current directory. The -key flag must point to a valid private key file.
Also confirm you are running the command from the ~/pki-lab directory. help me fix my CSR generation error
Make sure you copied the entire command including both backslash continuation lines.
Sign the server certificate and build the chain file
The CSR is a request. Now you need the Intermediate CA to approve it and issue the actual X.509 certificate.
- Sign the server certificate using the Intermediate CA by running this command:
openssl ca -config intermediate-ca/openssl.cnf \
-extensions server_cert \
-days 365 -notext -md sha256 \
-in server.csr \
-out server.crt
What happens when you run this?
OpenSSL will prompt you for the Intermediate CA private key passphrase (the one you set when creating the Intermediate CA key). After decrypting the key, it displays the certificate details and asks you to confirm the signing.
Type y and press Enter when asked "Sign the certificate?" and again when asked to commit.
💡 What does this command do?
- -config intermediate-ca/openssl.cnf tells OpenSSL to use the Intermediate CA's configuration (paths, policies, serial tracking).
- -extensions server_cert applies the [ server_cert ] section from that config, which sets CA:FALSE and extendedKeyUsage = serverAuth. This ensures the certificate can only be used as a server identity, never as a CA.
- -days 365 gives the certificate a one-year validity period. Server certificates are short-lived compared to CA certificates.
- Confirm the signed certificate was created by running:
ls server.crt
You should see server.crt in the output.
Now you need to assemble the full certificate chain. When a client (like a browser) connects to a server, the server presents its own certificate plus all intermediate certificates. The client then traces the chain back to a trusted root.
- Create the certificate chain file by concatenating the Intermediate CA and Root CA certificates:
cat intermediate-ca/certs/intermediate-ca.crt root-ca/certs/root-ca.crt > ca-chain.crt
Why this order matters
The chain file lists certificates from closest-to-the-server first (Intermediate CA) to the trust anchor last (Root CA). Verification tools walk the chain from bottom to top, matching each certificate's Issuer to the next certificate's Subject.
- Confirm the chain file was created by running:
ls ca-chain.crt
You should see ca-chain.crt in the output.
Signing failed with a policy error?
The Intermediate CA uses policy_loose which makes most subject fields optional. If you still see a policy error, check that the -subj in your CSR command included a CN value. The commonName = supplied rule means a Common Name must be present.
help me fix my certificate signing error
Verify the full chain of trust
You have all the pieces: a server certificate signed by the Intermediate CA, which was signed by the Root CA. Time to prove the chain is valid end-to-end.
- Verify the server certificate against the full chain by running:
openssl verify -CAfile ca-chain.crt server.crt
You should see server.crt: OK in the output. This confirms that OpenSSL can trace a valid trust path from server.crt through the Intermediate CA to the Root CA.
What does OK actually prove?
The verify command checks three things: the signatures in the chain are cryptographically valid, no certificate has expired, and the chain leads to a trusted anchor (provided via -CAfile). This is the same logic a browser performs behind the scenes when you visit an HTTPS site.
- Examine the server certificate details by running:
openssl x509 -noout -text -in server.crt
This prints the full certificate contents. Look for these three fields to confirm everything is correct:
- Issuer contains CN = PKI Lab Intermediate CA (proving the Intermediate CA signed it, not the Root).
- Subject contains CN = www.example-lab.com (the domain this certificate identifies).
- X509v3 Subject Alternative Name lists DNS:www.example-lab.com and DNS:example-lab.com (both domains the certificate is valid for).
Verification returned an error instead of OK?
If you see "unable to get local issuer certificate", your ca-chain.crt file may be incomplete. Re-run the cat command and make sure both certificate paths are correct.
If you see "certificate signature failure", the server certificate was not actually signed by the Intermediate CA key. Recreate server.crt using the signing command from the previous substep.
help me fix my certificate chain verification error
Your PKI can now issue real certificates. Next up, you will practice the other side of certificate management: revoking a compromised certificate and publishing that revocation so clients know to reject it.
Revoke a Certificate and Generate a CRL
You have issued a TLS certificate and verified it against the full chain. But what happens when a certificate is compromised or no longer needed?
In the real world, Certificate Authorities must be able to revoke certificates and publish that information so relying parties (browsers, servers, clients) can reject them. The mechanism for this is a Certificate Revocation List (CRL). In this step, you will revoke the server certificate you just issued, generate a CRL, and prove that verification now fails.
In this step, get ready to:
- Revoke the server certificate and confirm the database update.
- Generate a CRL and inspect its contents.
- Verify that a revoked certificate fails chain validation.
Revoke the server certificate
When a private key is compromised or a certificate should no longer be trusted, the issuing CA marks it as revoked in its internal database. This is the first step of the revocation process.
- Revoke the server certificate using the Intermediate CA by running this command:
openssl ca -config intermediate-ca/openssl.cnf -revoke server.crt
- Enter the Intermediate CA passphrase when prompted.
- OpenSSL will confirm the revocation. You should see output indicating the certificate has been revoked.
What does this command do?
The -revoke flag tells openssl ca to mark the specified certificate as revoked in the CA's database file (index.txt). The CA uses the certificate's serial number to record the revocation.
This is equivalent to what happens when you report a compromised certificate to a real CA like Let's Encrypt. The CA updates its records, but relying parties only find out once the CA publishes a new CRL.
- Confirm the revocation by checking the database file:
cat intermediate-ca/index.txt
- You should see the server certificate entry now starts with R (Revoked) instead of V (Valid).
What do the index.txt status codes mean?
The index.txt file is the CA's flat-file database. Each line represents a certificate the CA has issued. The first column is the status: V means Valid, R means Revoked, and E means Expired.
🙋♀️ Seeing a permissions or passphrase error?
Make sure you are running the command from the ~/pki-lab directory. The configuration file uses relative paths that only resolve correctly from this location.
If the passphrase is rejected, you are being asked for the Intermediate CA key passphrase (the one you set when generating intermediate-ca/private/intermediate-ca.key), not the Root CA passphrase.
help me troubleshoot why openssl ca -revoke is failing with a passphrase or permissions error
Generate the Certificate Revocation List
Revoking the certificate updated the CA's internal database. However, no one else knows about the revocation yet. CAs publish CRLs so that anyone verifying certificates can check whether they have been revoked.
- Generate a CRL from the Intermediate CA by running this command:
openssl ca -config intermediate-ca/openssl.cnf -gencrl -out intermediate-ca/crl/intermediate-ca.crl
- Enter the Intermediate CA passphrase when prompted.
What does -gencrl do?
The -gencrl flag tells OpenSSL to generate a CRL based on the information in the index.txt database. The resulting CRL file lists every revoked certificate's serial number and revocation date.
In production, CAs publish CRLs at regular intervals (the default_crl_days setting in your config is 30 days). Browsers and TLS clients download these lists to check whether a certificate they encounter has been revoked.
- Examine the CRL contents to confirm the revoked serial number appears:
openssl crl -in intermediate-ca/crl/intermediate-ca.crl -noout -text
- You should see a Revoked Certificates section listing the serial number of the server certificate and the revocation date.
What should I see in the CRL output?
The text output shows the CRL issuer (your Intermediate CA), the last update time, the next update time (30 days from now based on default_crl_days), and a list of revoked certificates. Each revoked entry shows the certificate's serial number and the date it was revoked.
🙋♀️ CRL file not created?
Make sure the intermediate-ca/crl/ directory exists. If it is missing, create it with mkdir -p intermediate-ca/crl and try again.
Also confirm intermediate-ca/crlnumber exists and contains a valid hex number. This file tracks CRL sequence numbers.
help me troubleshoot why my CRL generation is failing
Test verification against the CRL
The real proof that revocation works is when a relying party (like a browser or TLS client) rejects the revoked certificate. You can simulate this by telling openssl verify to check the CRL during chain validation.
- Verify the revoked certificate against the CRL by running this command:
openssl verify -crl_check -CRLfile intermediate-ca/crl/intermediate-ca.crl -CAfile ca-chain.crt server.crt
- You should see an error indicating the certificate has been revoked. The output will include a "certificate revoked" message, proving your revocation mechanism works.
What does -crl_check do?
The -crl_check flag checks end-entity certificate validity by attempting to look up a valid CRL. The -CRLfile flag points to the CRL file to check against. Together, they tell OpenSSL to reject any certificate whose serial number appears in the CRL.
This is exactly what a browser does behind the scenes when it checks whether a website's certificate has been revoked. If the serial number is in the CRL, the connection is rejected.
💡 Why does this matter in the real world?
Without revocation, a stolen certificate would remain trusted until it expires. CRLs (and their modern counterpart, OCSP) give CAs a way to immediately invalidate compromised certificates. Every browser, every Kubernetes cluster, and every corporate VPN checks revocation status before trusting a certificate.
🙋♀️ Not seeing the revoked error?
Make sure you generated the CRL after revoking the certificate. If you generated the CRL first and revoked the certificate afterwards, regenerate the CRL and try again.
Double-check that you are passing the correct CRL file path (intermediate-ca/crl/intermediate-ca.crl) and the correct chain file (ca-chain.crt).
help me understand why openssl verify is not showing a revoked error
Secret mission
Serve HTTPS with Your Own Certificates
You have a working PKI, but you have not actually served encrypted traffic with it yet. In this secret mission, you will spin up a local HTTPS server using your certificates, verify the full trust chain end-to-end, and learn why browsers still reject your CA.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine, so there are no ongoing cloud costs or subscriptions to worry about. Decide whether to keep your resources as they are, pause them, or delete them entirely.
Resources you used:
- The ~/pki-lab directory containing all CA files, keys, certificates, and configuration.
- OpenSSL 3.6.x installed via Homebrew.
- The openssl s_server process running on port 4443 (if you completed the Secret Mission and haven't stopped it yet).
Keep everything running
No action needed. Choose this if you plan to continue experimenting with your PKI, issuing more certificates, or extending the project.
- Your ~/pki-lab directory stays on your Desktop with all keys and certificates intact.
- OpenSSL remains installed via Homebrew and available for other projects.
- If the s_server process is still running, it will continue listening on port 4443 until you close that terminal window or press Ctrl+C.
Pause - I'll come back to this later
Stop the running server process to free up the port, but keep your files for later.
- If your openssl s_server is still running, switch to that terminal window and press Ctrl+C to stop it.
- Your ~/pki-lab directory and all certificates remain on disk. You can resume from where you left off at any time.
- The shell alias you created (alias openssl=...) only lasts for your current terminal session. You will need to re-create it when you return.
Delete - I don't want to use this again
Remove all project files and optionally uninstall OpenSSL to start completely fresh.
- If your openssl s_server is still running, switch to that terminal window and press Ctrl+C to stop it.
- Delete the entire project directory by running these commands in your terminal:
cd ~
rm -rf ~/pki-lab
- Confirm the directory is gone by running:
ls ~/pki-lab
You should see a "No such file or directory" message.
- To uninstall OpenSSL from Homebrew, run:
brew uninstall openssl@3
Other packages may depend on OpenSSL
Homebrew may prevent the uninstall if other installed packages depend on openssl@3. If you see a dependency error, it means another tool on your system needs OpenSSL. In that case, leave it installed.
Nice Work!
Nice Work!
You just built a complete Public Key Infrastructure from the ground up using OpenSSL. You now understand exactly what happens behind the padlock icon every time you visit a website.
You've learned how to:
- Created a two-tier CA hierarchy (Root CA and Intermediate CA) that mirrors how real certificate authorities operate, with proper X.509 v3 extensions, policies, and directory structures.
- Generated RSA key pairs, Certificate Signing Requests, and signed certificates with proper extensions, then verified the full chain of trust using OpenSSL's verify command.
- Revoked a certificate and generated a Certificate Revocation List to prove the revocation mechanism works, confirming that revoked certificates fail validation.
- Secret Mission: Served real HTTPS traffic using your own certificates with openssl s_server and verified end-to-end trust with openssl s_client and curl.
Ready to quiz yourself?