Case Studies · Last updated June 2026
Case Study: Building a Private PKI — Root CA, Subordinate CA, mTLS, and CRL Validation
Results at a Glance
- Private PKI deployed with a two-tier CA hierarchy: offline Root CA signing an online Subordinate CA
- Server certificates issued with correct SANs — clients trust them through the full chain without browser warnings
- CRL distribution operational — revoked certificates rejected within minutes of revocation
- Nginx hardened for mutual TLS: connections without a valid client certificate from this CA are rejected at the TLS handshake
The Problem
The internal infrastructure I was managing relied on a patchwork of self-signed certificates: each service generated its own cert, usually with a 10-year validity and no revocation mechanism. There was no chain of trust — every service had to explicitly trust every other service's certificate individually. And there was no way to enforce that only authorized clients could connect. TLS authenticated the server, but not the caller.
The goal was to replace this with a proper private PKI: a root of trust that could sign subordinate CAs, which in turn issued short-lived certificates for servers and clients — with a working revocation chain so a compromised certificate could be invalidated immediately rather than waiting for its expiry.
The CA Hierarchy
A two-tier hierarchy keeps the Root CA offline while the Subordinate CA handles day-to-day signing. If the Subordinate CA is compromised, it can be revoked and replaced without touching the Root.
Root CA (offline, 4096-bit RSA, 10-year validity)
└─ Subordinate CA (online, 2048-bit RSA, 3-year validity)
├─ server.internal (DNS+IP SANs, 1-year)
├─ client: service-a (1-year)
└─ client: service-b (1-year)
Step 1: Root CA
The Root CA is generated once and kept offline — its private key never touches an internet-connected machine after initial setup. Its only job is to sign the Subordinate CA certificate. The pathlen:0 basic constraint on the Subordinate CA limits the chain to one level, preventing the Subordinate from issuing further CAs.
# Generate Root CA private key (AES-256 encrypted)
openssl genrsa -aes256 -out root-ca.key 4096
# Self-sign the Root CA certificate (10 years)
openssl req -new -x509 -days 3650 \
-key root-ca.key \
-out root-ca.crt \
-subj "/C=NP/O=Internal/CN=Internal Root CA" \
-extensions v3_ca
# Verify
openssl x509 -in root-ca.crt -noout -text | grep -A2 "Basic Constraints"Step 2: Subordinate CA
The Subordinate CA is signed by the Root CA and stays online to handle certificate issuance. Its extensions include CRL Distribution Points and Authority Information Access (AIA) so that every certificate it issues automatically carries the revocation URLs — clients know where to check without any per-certificate configuration.
# Generate Subordinate CA key
openssl genrsa -aes256 -out sub-ca.key 2048
# Create CSR
openssl req -new -key sub-ca.key -out sub-ca.csr \
-subj "/C=NP/O=Internal/CN=Internal Subordinate CA"
# sub-ca-extensions.cnf
# [ v3_intermediate_ca ]
# subjectKeyIdentifier = hash
# authorityKeyIdentifier = keyid:always,issuer
# basicConstraints = critical, CA:true, pathlen:0
# keyUsage = critical, cRLSign, keyCertSign
# crlDistributionPoints = URI:http://crl.internal/sub-ca.crl
# authorityInfoAccess = OCSP;URI:http://ocsp.internal
# Sign with Root CA (3 years)
openssl x509 -req -days 1095 \
-in sub-ca.csr \
-CA root-ca.crt -CAkey root-ca.key -CAcreateserial \
-out sub-ca.crt \
-extfile sub-ca-extensions.cnf -extensions v3_intermediate_ca
# Build chain file (distributed to all servers and clients)
cat sub-ca.crt root-ca.crt > ca-chain.crtStep 3: Issuing Server Certificates
The private key is generated on the server and never leaves it. The CSR is sent to the CA for signing. Subject Alternative Names must cover every hostname and IP the server is reachable at — modern TLS clients ignore the CN field entirely and reject certificates without matching SANs.
# On the server: generate key and CSR
openssl genrsa -out server.key 2048
openssl req -new -key server.key -out server.csr \
-subj "/C=NP/O=Internal/CN=server.internal"
# server-san.cnf
# [ req_ext ]
# subjectAltName = @alt_names
# [ alt_names ]
# DNS.1 = server.internal
# DNS.2 = server
# IP.1 = 10.0.1.50
# Sign with Subordinate CA (1 year — short validity forces rotation)
openssl x509 -req -days 365 \
-in server.csr \
-CA sub-ca.crt -CAkey sub-ca.key -CAcreateserial \
-out server.crt \
-extfile server-san.cnf -extensions req_ext
# Verify chain
openssl verify -CAfile ca-chain.crt server.crtStep 4: CRL Distribution and Revocation
A Certificate Revocation List is a CA-signed file listing revoked certificate serial numbers. The distribution endpoint must be HTTP, not HTTPS — otherwise a client with a revoked certificate couldn't fetch the CRL to check its own revocation status.
# Generate initial (empty) CRL
openssl ca -gencrl \
-config sub-ca-openssl.cnf \
-out /var/www/crl/sub-ca.crl
# Revoke a compromised certificate
openssl ca -config sub-ca-openssl.cnf \
-revoke /path/to/compromised.crt \
-crl_reason keyCompromise
# Regenerate and publish the updated CRL
openssl ca -gencrl -config sub-ca-openssl.cnf \
-out /var/www/crl/sub-ca.crl
# Verify a certificate against the CRL
openssl verify \
-CAfile ca-chain.crt \
-crl_check -CRLfile sub-ca.crl \
server.crt
# Inspect CRL contents
openssl crl -in sub-ca.crl -noout -text | grep -A3 "Revoked"A cron job regenerates and publishes both Root and Subordinate CRL files every 24 hours — keeping them fresh within their validity window and ensuring servers always have a current revocation list to check against.
Step 5: Client Certificates for mTLS
Each service that needs to call the hardened server gets its own client certificate. The process mirrors server certificate issuance — the key is generated on the client, the CSR is submitted to the CA, and the Subordinate CA signs it. The CN identifies the client service, which the server application can use for authorization decisions.
# On client service A:
openssl genrsa -out client-a.key 2048
openssl req -new -key client-a.key -out client-a.csr \
-subj "/C=NP/O=Internal/CN=service-a"
# CA signs the client certificate
openssl x509 -req -days 365 \
-in client-a.csr \
-CA sub-ca.crt -CAkey sub-ca.key -CAcreateserial \
-out client-a.crt \
-extfile client-extensions.cnfStep 6: Nginx Hardened for Mutual TLS
With ssl_verify_client on, Nginx performs full chain validation — including the CRL check via ssl_crl — on every connection. A client without a valid certificate from the internal CA is rejected during the TLS handshake, so the request never reaches the application.
# /etc/nginx/conf.d/internal-service.conf
server {
listen 443 ssl;
server_name server.internal;
# Server identity
ssl_certificate /etc/ssl/server.crt;
ssl_certificate_key /etc/ssl/server.key;
# mTLS: require client cert from our CA
ssl_client_certificate /etc/ssl/ca-chain.crt;
ssl_verify_client on;
ssl_verify_depth 2; # Root -> Subordinate -> Client
# CRL validation on every connection
ssl_crl /etc/ssl/sub-ca.crl;
# TLS hardening — TLS 1.2+ only, strong cipher suites
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers on;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
location / {
# Pass client identity to the application
proxy_set_header X-Client-CN $ssl_client_s_dn_cn;
proxy_pass http://127.0.0.1:8080;
}
}Verifying the Full Chain End to End
Before hardening production, verify that both the happy path and the rejection case work correctly:
# mTLS handshake succeeds with a valid client certificate
openssl s_client \
-connect server.internal:443 \
-cert client-a.crt \
-key client-a.key \
-CAfile ca-chain.crt \
-verify_return_error
# Expected: Verify return code: 0 (ok)
# Connection is rejected without a client certificate
openssl s_client \
-connect server.internal:443 \
-CAfile ca-chain.crt
# Expected: SSL alert number 40 — handshake failure
# Revoked cert is rejected even if the cert itself is otherwise valid
openssl s_client \
-connect server.internal:443 \
-cert revoked-client.crt \
-key revoked-client.key \
-CAfile ca-chain.crt
# Expected: SSL alert — certificate revokedResults
The self-signed certificate sprawl was replaced with a centralized, auditable PKI. The CA database tracks every issued certificate — who requested it, when it was signed, when it expires. Revocation is near-instant: after revoking and republishing the CRL, every server picks up the updated file on its next refresh cycle.
Mutual TLS means the network perimeter is no longer the only security boundary. An attacker who gets onto the internal network still can't talk to these services without a certificate we issued.
What I'd Do Differently
- Use a dedicated CA tool (step-ca or CFSSL) rather than raw OpenSSL from the start — the OpenSSL CA database gets unwieldy as certificate count grows, and step-ca adds automatic renewal, ACME, and PKCS#11 HSM support.
- Add OCSP stapling alongside CRL from day one — OCSP gives per-connection freshness without clients downloading a growing CRL file, and Nginx supports it natively with
ssl_stapling on. - Automate certificate renewal early. One-year validity is correct, but 30 certificates expiring on different dates creates toil. A renewal daemon eliminates the manual rotation burden entirely.
Need internal PKI or mTLS for your infrastructure?
I design and implement certificate authority hierarchies, mTLS enforcement, and TLS hardening for internal services.
See Services