# Build a private CA with OpenSSL: root, intermediate, mTLS

> OpenSSL 3.x commands for a private CA: root and intermediate, server and client certificates, name constraints, CRLs, trust store import.

Source: https://ssl.sh/cookbook/private-ca/
Last updated: 2026-11-10

A private certificate authority issues certificates for internal services, test environments and mutual TLS without a public CA fee and without exposing internal hostnames in Certificate Transparency logs. The commands below, run with OpenSSL 3.6.1 in an empty directory, build a two-tier CA (offline root, online intermediate) and issue server and client certificates from it. Passwords are inline for brevity.

## Prepare the directory and configuration file

`openssl ca` keeps state in an index file, a serial file and a CRL number file; create them once and describe the CA in a config file.

```bash
mkdir -p pki/root pki/ica/newcerts pki/certs && cd pki
touch ica/index.txt && echo 1000 > ica/serial && echo 1000 > ica/crlnumber
chmod 700 root ica
```

```ini
# ca.cnf
[ ca ]
default_ca = intermediate_ca

[ intermediate_ca ]
dir               = ./ica
database          = $dir/index.txt
serial            = $dir/serial
crlnumber         = $dir/crlnumber
new_certs_dir     = $dir/newcerts
certificate       = $dir/ica.crt
private_key       = $dir/ica.key
default_md        = sha256
default_days      = 397
default_crl_days  = 7
policy            = policy_loose
copy_extensions   = copy
unique_subject    = no
crl_extensions    = crl_ext

[ policy_loose ]
countryName             = optional
organizationName        = optional
commonName              = supplied

[ v3_intermediate ]
basicConstraints        = critical, CA:TRUE, pathlen:0
keyUsage                = critical, keyCertSign, cRLSign
subjectKeyIdentifier    = hash
authorityKeyIdentifier  = keyid:always
nameConstraints         = critical, permitted;DNS:example.com, permitted;DNS:.example.com

[ server_cert ]
basicConstraints        = critical, CA:FALSE
keyUsage                = critical, digitalSignature
extendedKeyUsage        = serverAuth
subjectKeyIdentifier    = hash
authorityKeyIdentifier  = keyid
crlDistributionPoints   = URI:http://pki.example.com/ica.crl

[ client_cert ]
basicConstraints        = critical, CA:FALSE
keyUsage                = critical, digitalSignature
extendedKeyUsage        = clientAuth
subjectKeyIdentifier    = hash
authorityKeyIdentifier  = keyid

[ crl_ext ]
authorityKeyIdentifier  = keyid:always
```

```text
$ find . -maxdepth 2
./root
./ica
./ica/newcerts
./ica/serial
./ica/index.txt
./ica/crlnumber
./certs
./ca.cnf
```

`copy_extensions = copy` copies extensions such as the SAN from the CSR unless the config already sets them, while `server_cert` and `client_cert` fix the security-relevant ones (basic constraints, key usage, EKU). Never use `copyall` with CSRs you did not create: a requester could smuggle in `CA:TRUE`.

## Create the root CA key

Use an encrypted ECDSA P-384 key and keep it offline once the intermediate is signed.

```bash
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 -aes-256-cbc -out root/root.key
head -1 root/root.key
```

```text
Enter PEM pass phrase:
Verifying - Enter PEM pass phrase:
-----BEGIN ENCRYPTED PRIVATE KEY-----
```

`genpkey` writes PKCS#8 and, with a cipher option, encrypts it. RSA 4096 (`-algorithm RSA -pkeyopt rsa_keygen_bits:4096`) is the conservative alternative if some clients cannot handle ECDSA. More key types are in [generating keys](https://ssl.sh/cookbook/generate-keys/).

## Self-sign the root certificate

A root needs `basicConstraints=critical,CA:TRUE` and `keyUsage=keyCertSign,cRLSign`; ten years is a common lifetime.

```bash
openssl req -x509 -new -key root/root.key -sha384 -days 3650 \
  -subj "/C=CN/O=Example Corp/CN=Example Root CA" \
  -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign,cRLSign" \
  -addext "subjectKeyIdentifier=hash" \
  -out root/root.crt
openssl x509 -in root/root.crt -noout -subject -dates -ext basicConstraints,keyUsage
```

```text
subject=C=CN, O=Example Corp, CN=Example Root CA
notBefore=Nov  9 18:28:37 2026 GMT
notAfter=Nov  6 18:28:37 2036 GMT
X509v3 Basic Constraints: critical
    CA:TRUE
X509v3 Key Usage: critical
    Certificate Sign, CRL Sign
```

`-addext` works with `req -x509` since OpenSSL 1.1.1. Choose a distinctive Common Name: it is what administrators see in trust store listings.

## Create and sign the intermediate CA

Generate the intermediate key and CSR, then sign it with the root using the `v3_intermediate` section.

```bash
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -aes-256-cbc -out ica/ica.key
openssl req -new -key ica/ica.key -subj "/C=CN/O=Example Corp/CN=Example Intermediate CA" -out ica/ica.csr
openssl x509 -req -in ica/ica.csr -CA root/root.crt -CAkey root/root.key -days 1825 -sha384 \
  -extfile ca.cnf -extensions v3_intermediate -out ica/ica.crt
openssl x509 -in ica/ica.crt -noout -subject -issuer -ext basicConstraints,nameConstraints
```

```text
Certificate request self-signature ok
subject=C=CN, O=Example Corp, CN=Example Intermediate CA
subject=C=CN, O=Example Corp, CN=Example Intermediate CA
issuer=C=CN, O=Example Corp, CN=Example Root CA
X509v3 Basic Constraints: critical
    CA:TRUE, pathlen:0
X509v3 Name Constraints: critical
    Permitted:
      DNS:example.com
      DNS:.example.com
```

`pathlen:0` means this intermediate may issue end-entity certificates but no further CAs. After this step, move `root/root.key` to offline storage; you only need it again to sign a new intermediate or a root CRL.

## Issue a server certificate with openssl ca

`openssl ca` records every certificate in `index.txt`, which you need later for revocation and CRLs.

```bash
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -noenc \
  -keyout certs/www.key -subj "/CN=www.example.com" \
  -addext "subjectAltName=DNS:www.example.com,DNS:example.com" -out certs/www.csr
openssl ca -config ca.cnf -extensions server_cert -in certs/www.csr -out certs/www.crt -notext -batch
openssl x509 -in certs/www.crt -noout -ext subjectAltName,extendedKeyUsage
```

```text
Using configuration from ca.cnf
Enter pass phrase for ./ica/ica.key:
Check that the request matches the signature
Signature ok
The Subject's Distinguished Name is as follows
commonName            :ASN.1 12:'www.example.com'
Certificate is to be certified until Dec 11 18:29:09 2027 GMT (397 days)

Write out database with 1 new entries
Database updated
X509v3 Extended Key Usage: 
    TLS Web Server Authentication
X509v3 Subject Alternative Name: 
    DNS:www.example.com, DNS:example.com
```

`-noenc` (OpenSSL 3.0+) replaces the deprecated `-nodes` and leaves the server key unencrypted so a web server can load it. `-notext` stops `openssl ca` from writing a human-readable dump above the PEM block. Clients check only the SAN, so always include every hostname there; CSR options are covered in [creating CSRs](https://ssl.sh/cookbook/csr/).

## Sign a CSR without a CA database

`openssl x509 -req -CA` signs directly; add `-copy_extensions copyall` or the SAN from the CSR is lost.

```bash
openssl x509 -req -in certs/api.csr -CA ica/ica.crt -CAkey ica/ica.key -CAcreateserial -days 90 \
  -copy_extensions copyall -extfile ca.cnf -extensions server_cert -out certs/api.crt
openssl x509 -in certs/api.crt -noout -serial -ext subjectAltName
```

```text
Certificate request self-signature ok
subject=CN=api.example.com
serial=06AE478770F418A3316C8ACA2EA61D012B75A715
X509v3 Subject Alternative Name: 
    DNS:api.example.com
```

`-copy_extensions` for `x509` and `req` arrived in OpenSSL 3.0; on 1.1.1 put the SAN into the `-extfile` section instead. Use `copyall` only for CSRs you created yourself, because it copies every extension the requester asked for. Certificates issued this way are not in `index.txt`, so `openssl ca -revoke` knows nothing about them.

## Issue a client certificate for mutual TLS

Client certificates use the `clientAuth` EKU and usually identify a user or service in the subject.

```bash
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -noenc \
  -keyout certs/alice.key -subj "/O=Example Corp/CN=alice" -out certs/alice.csr
openssl ca -config ca.cnf -extensions client_cert -days 365 -in certs/alice.csr -out certs/alice.crt -notext -batch
openssl x509 -in certs/alice.crt -noout -subject -ext extendedKeyUsage
```

```text
Write out database with 1 new entries
Database updated
subject=O=Example Corp, CN=alice
X509v3 Extended Key Usage: 
    TLS Web Client Authentication
```

Public CAs are removing clientAuth: the Chrome Root Program began phasing clientAuth out of publicly trusted TLS hierarchies on 2026-06-15 (subscriber certificates issued from 2027-03-15 must assert only serverAuth), and Let's Encrypt's Generation Y certificates no longer include it. A private CA is the right source for mTLS client certificates; see [mTLS explained](https://ssl.sh/learn/mtls/) and [client auth EKU removed](https://ssl.sh/errors/client-auth-eku-removed/).

## Build the chain file and verify it

Servers send the leaf plus the intermediate; clients trust the root.

```bash
cat certs/www.crt ica/ica.crt > certs/www.fullchain.pem
openssl verify -show_chain -CAfile root/root.crt -untrusted ica/ica.crt -purpose sslserver certs/www.crt
openssl verify -CAfile root/root.crt -untrusted ica/ica.crt -purpose sslclient certs/alice.crt
```

```text
certs/www.crt: OK
Chain:
depth=0: CN=www.example.com (untrusted)
depth=1: C=CN, O=Example Corp, CN=Example Intermediate CA (untrusted)
depth=2: C=CN, O=Example Corp, CN=Example Root CA
certs/alice.crt: OK
```

Use `www.fullchain.pem` with Nginx `ssl_certificate`. For an mTLS server, point the client-CA setting (Nginx `ssl_client_certificate`, `ssl_verify_client on`) at the intermediate and root. More checks are in [verifying chains](https://ssl.sh/cookbook/verify-chains/).

## Package a client certificate as PKCS#12

Browsers, phones and Windows import client certificates as a password-protected `.p12` file.

```bash
openssl pkcs12 -export -in certs/alice.crt -inkey certs/alice.key -certfile ica/ica.crt \
  -name "alice@example.com" -out certs/alice.p12
openssl pkcs12 -in certs/alice.p12 -info -noout | head -3
```

```text
Enter Export Password:
Verifying - Enter Export Password:
MAC: sha256, Iteration 2048
MAC length: 32, salt length: 16
PKCS7 Encrypted data: PBES2, PBKDF2, AES-256-CBC, Iteration 2048, PRF hmacWithSHA256
```

Since OpenSSL 3.0 the default is AES-256-CBC with PBKDF2 and a SHA-256 MAC. Older Windows releases, old Java versions and some embedded devices cannot read that; for them add `-legacy` (RC2/3DES with SHA-1 MAC) or `-certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES -macalg sha1`. Details in [PKCS#12 commands](https://ssl.sh/cookbook/pkcs12/).

## Restrict the CA to your own domains

The `nameConstraints` in the intermediate make any certificate for another domain fail verification, even though the CA signed it.

```bash
openssl ca -config ca.cnf -extensions server_cert -in certs/shop.csr -out certs/shop.crt -notext -batch
openssl verify -CAfile root/root.crt -untrusted ica/ica.crt certs/shop.crt
```

```text
CN=shop.example.net
error 47 at 0 depth lookup: permitted subtree violation
error certs/shop.crt: verification failed
```

Name constraints limit the damage if the intermediate key leaks, and they make it easier for other teams to agree to trust your root. `permitted;DNS:.example.com` matches subdomains; add `permitted;DNS:example.com` for the apex and `permitted;IP:10.0.0.0/255.0.0.0` for internal IP addresses. Mark the extension critical so clients that do not understand it reject the certificate rather than ignore the constraint.

## Revoke a certificate and publish a CRL

`openssl ca -revoke` marks a certificate in `index.txt`; `-gencrl` writes the signed CRL that clients download.

```bash
openssl ca -config ca.cnf -revoke certs/alice.crt -crl_reason keyCompromise
openssl ca -config ca.cnf -gencrl -out ica/ica.crl
openssl verify -crl_check -CAfile root/root.crt -untrusted ica/ica.crt -CRLfile ica/ica.crl certs/alice.crt
```

```text
Using configuration from ca.cnf
Revoking Certificate 1001.
Database updated
Using configuration from ca.cnf
O=Example Corp, CN=alice
error 23 at 0 depth lookup: certificate revoked
error certs/alice.crt: verification failed
```

`default_crl_days = 7` means the CRL expires after a week, so regenerate it from cron and publish it at the URL in `crlDistributionPoints`. Nginx checks client certificates against a CRL with `ssl_crl`. For an OCSP test responder see [OCSP and CRL commands](https://ssl.sh/cookbook/ocsp-crl/).

## Trust the root on Linux, macOS, Windows and Java

Each platform has its own trust store; add only the root, never the intermediate.

```bash
# Debian / Ubuntu (file must end in .crt)
sudo cp root/root.crt /usr/local/share/ca-certificates/example-root-ca.crt && sudo update-ca-certificates
# RHEL / Rocky / Alma
sudo cp root/root.crt /etc/pki/ca-trust/source/anchors/ && sudo update-ca-trust
# macOS
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain root/root.crt
# Windows (elevated prompt)
certutil -addstore -f Root root.crt
# Java (cacerts of the running JDK)
keytool -importcert -cacerts -alias example-root-ca -file root/root.crt -noprompt -storepass changeit
```

```text
Updating certificates in /etc/ssl/certs...
1 added, 0 removed; done.
Running hooks in /etc/ca-certificates/update.d...
done.
Certificate was added to keystore
```

Firefox uses its own store unless the `security.enterprise_roots.enabled` preference or an enterprise policy imports system roots. Containers need the root baked into the image. Python `requests` uses the certifi bundle by default, so point `REQUESTS_CA_BUNDLE` at your root, or you will see [CERTIFICATE_VERIFY_FAILED](https://ssl.sh/errors/python-certificate-verify-failed/). Keep server certificates at or below 825 days for Apple clients, and prefer much shorter lifetimes with automated renewal.
