How-to guide · Labs and advanced
How to build a test CA with OpenSSL (lab only)
In one sentence
You will have a two-tier test CA built with openssl ca: a root, a name-constrained issuing CA, server and client certificates, a revocation and working CRLs.
- Platforms
- Linux
- macOS
- Windows
- Level
- Practitioner · 6 steps
On this page
Before you start
- OpenSSL 3.0 or later (
openssl version). Git Bash on Windows, Linux, macOS and WSL all work. - An empty folder outside any code repository, so test keys are never committed.
- In Git Bash, run
export MSYS_NO_PATHCONV=1first. Otherwise Git Bash turns subjects such as"/CN=…"into Windows paths.
Why this matters: the quick openssl x509 -req recipe makes certificates, but not a CA. openssl ca keeps a database of what it issued, so it can revoke certificates and publish CRLs, and a config file makes every certificate follow the same profile. That is what you need to test real behaviour.
Steps
1. Write the configuration
Save this as ca.cnf. It defines two CAs (ca_root and ca_int) and four certificate profiles:
# ca.cnf: a two-tier TEST CA for labs. Not for production.
[ ca ]
default_ca = ca_int # leaves are signed by the intermediate
[ ca_root ]
dir = ./root
database = $dir/index.txt
serial = $dir/serial
crlnumber = $dir/crlnumber
new_certs_dir = $dir/newcerts
certificate = $dir/root.crt
private_key = $dir/private/root.key
default_md = sha256
default_days = 1825
default_crl_days = 30
crl_extensions = crl_ext
policy = policy_loose
unique_subject = no
copy_extensions = none
[ ca_int ]
dir = ./intermediate
database = $dir/index.txt
serial = $dir/serial
crlnumber = $dir/crlnumber
new_certs_dir = $dir/newcerts
certificate = $dir/intermediate.crt
private_key = $dir/private/intermediate.key
default_md = sha256
default_days = 30
default_crl_days = 7
crl_extensions = crl_ext
policy = policy_loose
unique_subject = no
copy_extensions = copy # take subjectAltName from the CSR
[ policy_loose ]
countryName = optional
organizationName = optional
commonName = supplied
[ req ]
distinguished_name = req_dn
prompt = no
[ req_dn ]
CN = unused
[ v3_root ]
basicConstraints = critical, CA:TRUE
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
[ v3_intermediate ]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
nameConstraints = critical, permitted;DNS:lab.test
crlDistributionPoints = URI:http://pki.lab.test/root.crl
[ server_cert ]
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = serverAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid
crlDistributionPoints = URI:http://pki.lab.test/intermediate.crl
[ client_cert ]
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid
crlDistributionPoints = URI:http://pki.lab.test/intermediate.crl
[ crl_ext ]
authorityKeyIdentifier = keyid:always
The choices that matter:
pathlen:0stops the issuing CA from creating further CAs.nameConstraintslimits the issuing CA tolab.testand its subdomains, so even a misused test CA cannot produce a certificate that clients accept for a real domain. Use a name you own, or a reserved one such as.test.copy_extensions = copytakes the Subject Alternative Name from the CSR. It never overrides an extension the profile already sets, so a request cannot make itself a CA. A real CA still checks every requested name.- Leaf certificates live 30 days and CRLs 7, short enough that you will see expiry happen.
Then create the folders and the database files:
for d in root intermediate; do
mkdir -p $d/private $d/newcerts
: > $d/index.txt
echo 1000 > $d/serial
echo 1000 > $d/crlnumber
done
chmod 700 root/private intermediate/private
index.txt is the database of issued certificates, serial and crlnumber hold the next numbers to use, and newcerts keeps a copy of everything the CA signs. Even in a lab, keep the private folders readable only by you.
2. Create the root
openssl req -x509 -config ca.cnf -extensions v3_root \
-newkey ec -pkeyopt ec_paramgen_curve:P-384 -noenc \
-keyout root/private/root.key -out root/root.crt \
-days 3650 -subj "/O=Example Lab/CN=Example Lab Test Root"
-noenc leaves the key unencrypted, which is acceptable only because this is a throwaway lab. Leave it out and OpenSSL asks for a passphrase.
3. Create the issuing CA
The issuing CA makes its own key and CSR, and the root signs it with the v3_intermediate profile:
openssl req -new -config ca.cnf \
-newkey ec -pkeyopt ec_paramgen_curve:P-256 -noenc \
-keyout intermediate/private/intermediate.key -out intermediate/intermediate.csr \
-subj "/O=Example Lab/CN=Example Lab Test Issuing CA"
openssl ca -batch -config ca.cnf -name ca_root -extensions v3_intermediate \
-in intermediate/intermediate.csr -out intermediate/intermediate.crt
openssl x509 -in intermediate/intermediate.crt -noout -subject -issuer -ext basicConstraints,nameConstraints
subject=O=Example Lab, CN=Example Lab Test Issuing CA
issuer=O=Example Lab, CN=Example Lab Test Root
X509v3 Basic Constraints: critical
CA:TRUE, pathlen:0
X509v3 Name Constraints: critical
Permitted:
DNS:lab.test
4. Issue a server certificate
The server creates its key and a CSR with its names; the issuing CA signs it with the server_cert profile:
openssl req -new -config ca.cnf -newkey ec -pkeyopt ec_paramgen_curve:P-256 -noenc \
-keyout www.key -out www.csr -subj "/CN=www.lab.test" \
-addext "subjectAltName=DNS:www.lab.test,DNS:lab.test"
openssl ca -batch -config ca.cnf -extensions server_cert -in www.csr -out www.crt
openssl x509 -in www.crt -noout -subject -issuer -ext subjectAltName,extendedKeyUsage
subject=CN=www.lab.test
issuer=O=Example Lab, CN=Example Lab Test Issuing CA
X509v3 Extended Key Usage:
TLS Web Server Authentication
X509v3 Subject Alternative Name:
DNS:www.lab.test, DNS:lab.test
Servers need the leaf followed by the issuing CA: cat www.crt intermediate/intermediate.crt > www-fullchain.pem.
5. Issue a client certificate
The same, with the client_cert profile, for mutual TLS tests:
openssl req -new -config ca.cnf -newkey ec -pkeyopt ec_paramgen_curve:P-256 -noenc \
-keyout alice.key -out alice.csr -subj "/O=Example Lab/CN=alice"
openssl ca -batch -config ca.cnf -extensions client_cert -in alice.csr -out alice.crt
cat intermediate/index.txt
V 261119022517Z 1000 unknown /CN=www.lab.test
V 261119022517Z 1001 unknown /O=Example Lab/CN=alice
index.txt is the CA’s record: status (V for valid), expiry, serial number and subject.
6. Revoke a certificate and publish CRLs
openssl ca -config ca.cnf -revoke alice.crt -crl_reason keyCompromise
openssl ca -config ca.cnf -gencrl -out intermediate/intermediate.crl
openssl ca -config ca.cnf -name ca_root -gencrl -out root/root.crl
openssl crl -in intermediate/intermediate.crl -noout -text | head -17
Certificate Revocation List (CRL):
Version 2 (0x1)
Signature Algorithm: ecdsa-with-SHA256
Issuer: O=Example Lab, CN=Example Lab Test Issuing CA
Last Update: Oct 20 02:25:54 2026 GMT
Next Update: Oct 27 02:25:54 2026 GMT
CRL extensions:
X509v3 Authority Key Identifier:
A5:5C:08:19:C0:25:EE:EF:7C:D7:23:57:67:EB:5F:E1:56:AC:42:9F
X509v3 CRL Number:
4096
Revoked Certificates:
Serial Number: 1001
Revocation Date: Oct 20 02:25:41 2026 GMT
CRL entry extensions:
X509v3 CRL Reason Code:
Key Compromise
Each CA publishes its own CRL: the root’s lists revoked CAs, the issuing CA’s lists revoked leaves. Regenerate both before their Next Update, or clients that check them will reject everything.
Check it worked
Verify as a client would: trust only the root, supply the issuing CA as an untrusted helper, and check the purpose:
openssl verify -CAfile root/root.crt -untrusted intermediate/intermediate.crt -purpose sslserver www.crt
openssl verify -CAfile root/root.crt -untrusted intermediate/intermediate.crt -purpose sslclient www.crt
www.crt: OK
CN=www.lab.test
error 26 at 0 depth lookup: unsuitable certificate purpose
error www.crt: verification failed
Then check revocation with both CRLs. To test the name constraint, repeat step 4 for www.example.com (key ext.key, certificate ext.crt); the issuing CA signs it without complaint. Verify all three:
cat root/root.crl intermediate/intermediate.crl > crls.pem
openssl verify -crl_check_all -CAfile root/root.crt -untrusted intermediate/intermediate.crt \
-CRLfile crls.pem www.crt alice.crt
openssl verify -CAfile root/root.crt -untrusted intermediate/intermediate.crt ext.crt
www.crt: OK
O=Example Lab, CN=alice
error 23 at 0 depth lookup: certificate revoked
error alice.crt: verification failed
CN=www.example.com
error 47 at 0 depth lookup: permitted subtree violation
error ext.crt: verification failed
The CA signed www.example.com; the client refused it.
Finally, try a real handshake. OpenSSL’s test server sends the leaf and the issuing CA, and the client trusts only your root:
openssl s_server -accept 127.0.0.1:4433 -cert www.crt -key www.key \
-cert_chain intermediate/intermediate.crt -www &
openssl s_client -connect 127.0.0.1:4433 -servername www.lab.test \
-CAfile root/root.crt -verify_hostname www.lab.test -brief </dev/null
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Peer certificate: CN=www.lab.test
Hash used: SHA256
Signature type: ECDSA
Verification: OK
Verified peername: www.lab.test
Stop the test server afterwards with kill %1. Paste www-fullchain.pem and the root into the chain checker to see the path:
If something goes wrong
error 20 … unable to get local issuer certificate: the client does not trust your root, or the issuing CA was not supplied. See OpenSSL verify error 20.error 3 … unable to get certificate CRL: you asked for-crl_checkbut gave no CRL for that CA. With-crl_check_all, every CA in the chain needs one.error 12 … CRL has expiredorcertificate has expired: regenerate the CRL, or issue a new certificate (certificate has expired). Short lifetimes in a lab make this easy to rehearse.The commonName field needed to be supplied and was missing: the CSR has no CN, whichpolicy_looserequires. Add-subj "/CN=…".This name is not in that format: 'C:/Program Files/Git/CN=www.lab.test'in Git Bash: the shell rewrote the subject into a path. SetMSYS_NO_PATHCONV=1.- You want to start again: delete the
rootandintermediatefolders and repeat from step 1. Never reuse a test root’s key for anything else.
Why this works
A chain is valid when each certificate is signed by the next one up, each CA certificate is marked as a CA, and the top is a root the client already trusts (certificate chains). The issuing CA’s pathlen:0 and name constraints are checked by the client, not the CA, which is why the bad certificate was signed but rejected. Revocation works the same way: the CA lists serial numbers in a signed CRL, and only clients that fetch and check it are protected (revocation). The production version of this design, with an offline root, is in A CA of your own.
Go deeper
- OpenSSL: openssl-ca: every configuration option used above, including
copy_extensions. - OpenSSL: x509v3_config: the extension syntax for profiles.
- OpenSSL: openssl-verify: purposes, CRL checks and error codes.
- RFC 5280: sections 4.2.1.9 (basic constraints), 4.2.1.10 (name constraints) and 5 (CRLs).
Early edition. Checked against the standards; an independent expert review is in progress. Found a mistake? Tell us.