<!-- https://pkizerotohero.com/guides/openssl-test-ca/ | Markdown mirror of the HTML page | PKI Zero to Hero, by Duyutech BV -->

> Build a two-tier test CA with OpenSSL for labs: config file, root and issuing CA, server and client certificates, name constraints, revocation and CRLs.

Source: https://pkizerotohero.com/guides/openssl-test-ca/ · Last reviewed: 2026-10-20

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.

Task

[Labs and advanced](https://pkizerotohero.com/guides/#task-advanced)

Platforms

- Linux
- macOS
- Windows

Who it is for

- [Developer](https://pkizerotohero.com/start/#developer)
- [IT ops](https://pkizerotohero.com/start/#itops)
- [Expert](https://pkizerotohero.com/start/#expert)

Level

Practitioner · 6 steps

Warning

For labs and tests only

This CA keeps unencrypted keys in files and has no audit trail, no HSM and no policy. Use it to test software, learn how chains and CRLs behave, or reproduce errors. Never let anything outside your lab trust its root. For a real private PKI, read [A CA of your own](https://pkizerotohero.com/handbook/private-ca/), or use a CA product such as [step-ca](https://pkizerotohero.com/guides/private-ca-step-ca/).

## 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=1` first. 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](https://pkizerotohero.com/handbook/glossary/#crl), 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:

```text
# 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:0`** stops the issuing CA from creating further CAs.
- **`nameConstraints`** limits the issuing CA to `lab.test` and 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 = copy`** takes the [Subject Alternative Name](https://pkizerotohero.com/handbook/glossary/#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:

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

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

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

```text
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:

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

```text
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](https://pkizerotohero.com/guides/mutual-tls-setup/) tests:

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

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

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

```text
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:

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

```text
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:

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

```text
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:

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

```text
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:

*Interactive tool: Chain checker (runs in the browser at [/tools/chain/](https://pkizerotohero.com/tools/chain/))*

## 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](https://pkizerotohero.com/errors/openssl-verify-error-20/).
- **`error 3 … unable to get certificate CRL`**: you asked for `-crl_check` but gave no CRL for that CA. With `-crl_check_all`, every CA in the chain needs one.
- **`error 12 … CRL has expired`** or **`certificate has expired`**: regenerate the CRL, or issue a new certificate ([certificate has expired](https://pkizerotohero.com/errors/openssl-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, which `policy_loose` requires. 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. Set `MSYS_NO_PATHCONV=1`.
- **You want to start again**: delete the `root` and `intermediate` folders 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](https://pkizerotohero.com/handbook/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](https://pkizerotohero.com/handbook/revocation/)). The production version of this design, with an offline root, is in [A CA of your own](https://pkizerotohero.com/handbook/private-ca/).

## Go deeper

- [OpenSSL: openssl-ca](https://docs.openssl.org/master/man1/openssl-ca/): every configuration option used above, including `copy_extensions`.
- [OpenSSL: x509v3\_config](https://docs.openssl.org/master/man5/x509v3_config/): the extension syntax for profiles.
- [OpenSSL: openssl-verify](https://docs.openssl.org/master/man1/openssl-verify/): purposes, CRL checks and error codes.
- [RFC 5280](https://www.rfc-editor.org/rfc/rfc5280): 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](https://pkizerotohero.com/contact/?topic=content-error&page=/guides/openssl-test-ca/).

Last reviewed 20 October 2026Edition 0.9

## Keep going

### Learn why it works

- [Chapter 22: A CA of your own: private two-tier PKI](https://pkizerotohero.com/handbook/private-ca/)
- [Chapter 13: Certificate chains and path validation](https://pkizerotohero.com/handbook/certificate-chains/)
- [Chapter 16: Taking it back: CRLs, OCSP and stapling](https://pkizerotohero.com/handbook/revocation/)

### Error pages

- [verify error:num=20:unable to get local issuer certificate](https://pkizerotohero.com/errors/openssl-verify-error-20/)
- [verify error:num=10:certificate has expired](https://pkizerotohero.com/errors/openssl-certificate-has-expired/)

### Tools

- [Chain checker](https://pkizerotohero.com/tools/chain/): Paste a chain and see which link is missing, expired or out of order.
- [Certificate decoder](https://pkizerotohero.com/tools/decoder/): Paste a PEM certificate and read every field in plain words.

### Topics

- [Private PKI](https://pkizerotohero.com/topics/private-pki/)

## More guides: Labs and advanced

- [How to run a small private CA with step-ca (lab)](https://pkizerotohero.com/guides/private-ca-step-ca/)
- [How to sign code with signtool and cosign](https://pkizerotohero.com/guides/code-signing-signtool-cosign/)
- [How to use SSH certificates instead of known\_hosts](https://pkizerotohero.com/guides/ssh-certificates/)
- [How to test post-quantum hybrid TLS](https://pkizerotohero.com/guides/post-quantum-tls-test/)
