Servers Security

Building a Two-Tier AD CS PKI: Offline Root and Enterprise Issuing CA

Most internal PKIs fail in one of two ways: someone stands up an Enterprise Root CA on a domain controller (so the root key is online and impossible to protect), or they build a two-tier hierarchy but never plan the CRL/AIA lifecycle, so eighteen months later certificate validation breaks everywhere at once when the root CRL silently expires.

This guide builds the boring, correct thing: an offline standalone root CA that lives powered off in a safe, and an enterprise subordinate issuing CA that is domain-joined and does the day-to-day work. We will wire up CRL Distribution Points (CDP) and Authority Information Access (AIA) to an HTTP location, author templates with sane EKUs, turn on autoenrollment, and document the renewal procedures that keep the whole thing alive.

Assumptions: Windows Server 2022, an existing AD forest, and an HTTP server (IIS) reachable at pki.corp.example.com for publishing CRLs and CA certificates. Replace names and OIDs with your own.

1. Hierarchy design: root vs. issuing CA

The split of responsibilities is the entire point of two tiers:

Property Offline Root CA Enterprise Issuing CA
CA type Standalone Root Enterprise Subordinate
Domain joined No (workgroup) Yes
Powered on Only to issue/renew sub CA and publish CRL Always
Key length RSA 4096 RSA 4096 (or 3072)
Hash SHA-256 SHA-256
Validity 20 years (self) 10 years (issued by root)
Issued cert lifetime The sub CA cert only End-entity certs (1-2 years)
Root CRL period 26-52 weeks N/A for root
Issuing CRL period N/A 1 week + delta

Two rules that prevent most production incidents:

A subordinate CA can never issue certificates with a validity longer than its own certificate. Give the issuing CA a 10-year cert so a 2-year end-entity cert issued in year 9 still fits.

The offline root’s CRL validity must be long (26-52 weeks) because nobody will remember to boot it. But “long” is not “infinite” — a missed root CRL renewal is a forest-wide outage. Put it on the calendar.

Use SHA-256. Do not use SHA-1 (deprecated and untrusted by modern clients). SHA-384/512 is fine cryptographically but adds interop friction with older network gear; SHA-256 is the pragmatic default.

2. Build the offline root with a CAPolicy.inf

The root is a workgroup (non-domain-joined) machine. Before installing the CA role, drop a CAPolicy.inf into C:\Windows. This file controls the root’s own self-signed certificate and, critically, suppresses CDP/AIA extensions in the root certificate — an offline root should not advertise a CRL location in its own cert because it never gets revoked by anyone.

[Version]
Signature="$Windows NT$"

[Certsrv_Server]
RenewalKeyLength=4096
RenewalValidityPeriod=Years
RenewalValidityPeriodUnits=20
CRLPeriod=Weeks
CRLPeriodUnits=26
CRLDeltaPeriod=Days
CRLDeltaPeriodUnits=0
AlternateSignatureAlgorithm=0
LoadDefaultTemplates=0

[PolicyStatementExtension]
Policies=InternalPolicy

[InternalPolicy]
OID=1.3.6.1.4.1.311.21.8.0000000.0000000.0000000.0000000.1
Notice="This CA is operated by Example Corp. Issuance is governed by the Example Corp CPS."

Key points:

Now install the role and configure it as a standalone root. Run elevated PowerShell:

# On the OFFLINE root (workgroup machine)
Install-WindowsFeature ADCS-Cert-Authority -IncludeManagementTools

Install-AdcsCertificationAuthority `
  -CAType StandaloneRootCA `
  -CACommonName "Example Corp Root CA" `
  -KeyLength 4096 `
  -HashAlgorithmName SHA256 `
  -CryptoProviderName "RSA#Microsoft Software Key Storage Provider" `
  -ValidityPeriod Years `
  -ValidityPeriodUnits 20 `
  -Force

Production note: for a real root, back the key with a hardware security module (HSM) and use that vendor’s KSP instead of the software provider. The software KSP is acceptable only if the root machine is genuinely offline and the key is exported and stored in a safe.

3. Configure CDP/AIA and publish the root CRL

This is the step everyone botches. By default the CA writes CDP/AIA pointing to LDAP and to a local C:\Windows\system32\CertSrv\CertEnroll path that does not exist for an offline machine. We want certificates issued by the root (i.e., the sub CA cert) to reference an HTTP CRL that clients can actually reach.

Set the CRL validity, then rewrite the CDP and AIA on the root using certutil:

# CRL period for the offline root: 26 weeks, no delta
certutil -setreg CA\CRLPeriodUnits 26
certutil -setreg CA\CRLPeriod "Weeks"
certutil -setreg CA\CRLDeltaPeriodUnits 0
certutil -setreg CA\CRLDeltaPeriod "Days"

# Validity of certs the root issues (the sub CA): 10 years
certutil -setreg CA\ValidityPeriodUnits 10
certutil -setreg CA\ValidityPeriod "Years"

Now the CDP. The numeric flag prefixes are bit flags certutil understands: 1: publish to CRL file, 2: include in CDP extension, 4: include in the CRL freshest-CRL field, 8: delta, 64: IDP. We keep the local file publication (no flag prefix variations needed beyond 1) and add an HTTP CDP entry:

# Drop the default LDAP/HTTP/file CDP entries, then add ours.
# Keep local file publish (so the .crl is written to disk), add HTTP for clients.
certutil -setreg CA\CRLPublicationURLs "1:C:\Windows\system32\CertSrv\CertEnroll\%3%8%9.crl\n2:http://pki.corp.example.com/cdp/%3%8%9.crl"

# AIA: publish the root cert over HTTP only (no LDAP, machine is offline)
certutil -setreg CA\CACertPublicationURLs "1:C:\Windows\system32\CertSrv\CertEnroll\%1_%3%4.crt\n2:http://pki.corp.example.com/cdp/%1_%3%4.crt"

The %n tokens are CA replacement variables: %1 = server DNS name, %3 = CA name (sanitized), %4 = cert name suffix (renewal index), %8 = delta CRL marker, %9 = CDP extension suffix. After changing registry, restart the service and publish a fresh CRL:

Restart-Service certsvc
certutil -crl

You now have two files under C:\Windows\System32\CertSrv\CertEnroll:

Copy both off the offline machine (USB/data diode) to the IIS box and place them at the path mapped to http://pki.corp.example.com/cdp/. Also publish the root cert into Active Directory so every domain member trusts it without manual import:

# Run on a DOMAIN-JOINED admin box, with the root .crt copied over
certutil -dspublish -f "Example Corp Root CA.crt" RootCA

4. Install and submit the subordinate issuing CA request

Move to the domain-joined server that will be the issuing CA. Optionally give it its own short CAPolicy.inf to set the sub CA’s CRL periods up front:

[Version]
Signature="$Windows NT$"

[Certsrv_Server]
RenewalKeyLength=4096
RenewalValidityPeriod=Years
RenewalValidityPeriodUnits=10
LoadDefaultTemplates=0
AlternateSignatureAlgorithm=0

Install the role and configure as Enterprise Subordinate. Because the parent (offline root) is not online, the installer cannot auto-submit, so it writes a .req request file to disk:

Install-WindowsFeature ADCS-Cert-Authority -IncludeManagementTools

Install-AdcsCertificationAuthority `
  -CAType EnterpriseSubordinateCA `
  -CACommonName "Example Corp Issuing CA 01" `
  -KeyLength 4096 `
  -HashAlgorithmName SHA256 `
  -CryptoProviderName "RSA#Microsoft Software Key Storage Provider" `
  -OutputCertRequestFile "C:\IssuingCA01.req" `
  -Force

The command finishes with a warning that the CA is not yet started because it lacks a certificate. That is expected. Transfer C:\IssuingCA01.req to the offline root and submit it:

# On the OFFLINE root
certreq -submit "C:\IssuingCA01.req"
# Note the RequestId it prints, then approve and retrieve:
certutil -resubmit <RequestId>
certreq -retrieve <RequestId> "C:\IssuingCA01.crt"

If certreq -submit returns “Certificate request is pending: Taken Under Submission”, that is normal for a standalone CA — the request sits in the Pending Requests queue until you certutil -resubmit it. Carry IssuingCA01.crt back to the issuing CA and install it, then start the service:

# On the issuing CA
certutil -installcert "C:\IssuingCA01.crt"
Start-Service certsvc

Verify the chain immediately:

certutil -verify -urlfetch (Get-ChildItem Cert:\LocalMachine\My | `
  Where-Object Subject -like "*Issuing CA 01*").PSPath

-urlfetch forces the validator to actually download the root cert and CRL from your HTTP CDP/AIA — this is the moment you find out whether step 3 was correct.

Configure the issuing CA’s own CDP/AIA

Repeat the CDP/AIA wiring on the issuing CA, but here we do want delta CRLs (end-entity certs get revoked) and LDAP publication (clients are domain-joined). The issuing CA publishes to AD, to local file, and to HTTP:

certutil -setreg CA\CRLPeriodUnits 1
certutil -setreg CA\CRLPeriod "Weeks"
certutil -setreg CA\CRLDeltaPeriodUnits 1
certutil -setreg CA\CRLDeltaPeriod "Days"
certutil -setreg CA\CRLOverlapUnits 12
certutil -setreg CA\CRLOverlapPeriod "Hours"

certutil -setreg CA\CRLPublicationURLs "65:C:\Windows\system32\CertSrv\CertEnroll\%3%8%9.crl\n79:ldap:///CN=%7%8,CN=%2,CN=CDP,CN=Public Key Services,CN=Services,%6%10\n6:http://pki.corp.example.com/cdp/%3%8%9.crl"

certutil -setreg CA\CACertPublicationURLs "3:C:\Windows\system32\CertSrv\CertEnroll\%1_%3%4.crt\n3:ldap:///CN=%7,CN=AIA,CN=Public Key Services,CN=Services,%6%11\n2:http://pki.corp.example.com/cdp/%1_%3%4.crt"

Restart-Service certsvc
certutil -crl

Set validity for end-entity certs (most templates override this, but set a ceiling):

certutil -setreg CA\ValidityPeriodUnits 2
certutil -setreg CA\ValidityPeriod "Years"
Restart-Service certsvc

5. Author and duplicate certificate templates

Never edit the built-in templates. Duplicate them so you control the version and settings. Open certtmpl.msc, duplicate Workstation Authentication (or Computer), and configure:

Hard-won lesson: a template’s minimum key size is a floor, not a default. If you set 4096 on a template but clients/HSMs generate 2048, enrollment fails. Match the template floor to what your fleet can actually produce.

After authoring, publish the template to the issuing CA so it can issue from it:

# Publish a template to the issuing CA (use the template's internal name)
Add-CATemplate -Name "CorpComputerAuth" -Force

# Confirm what the CA will issue
Get-CATemplate

For server/TLS templates where you need a SAN, prefer build from AD or supply-in-request only for tightly controlled, manually approved templates — an unguarded “supply in request” template plus Enroll for Authenticated Users is a privilege-escalation path (ESC1). Audit for it.

6. Enable autoenrollment via Group Policy

Autoenrollment lets domain members silently request and renew certs from a published template. Create or edit a GPO linked at the domain (or an OU):

Computer Configuration -> Policies -> Windows Settings -> Security Settings -> Public Key Policies -> Certificate Services Client - Auto-Enrollment

Set Configuration Model: Enabled, and check:

For user certs, set the matching policy under User Configuration. Then trigger a refresh on a test machine and pull a cert:

gpupdate /force
certutil -pulse           # kick the autoenrollment engine immediately
Get-ChildItem Cert:\LocalMachine\My | Format-List Subject, Issuer, NotAfter

Verify

Run these and confirm each passes before declaring victory.

# 1. Chain builds and revocation is reachable end to end
certutil -verify -urlfetch C:\some-issued-cert.cer
# Look for: "Verified Issuance Policies", "Leaf certificate revocation check passed"

# 2. The HTTP CDP actually serves the CRLs (no 404, correct MIME)
Invoke-WebRequest "http://pki.corp.example.com/cdp/Example%20Corp%20Issuing%20CA%2001.crl" -OutFile $env:TEMP\test.crl
certutil -dump $env:TEMP\test.crl

# 3. CA health and pending CRL publish times
certutil -CAInfo
certutil -getreg CA\CRLPublicationURLs
certutil -getreg CA\CRLOverlapUnits

# 4. Root cert is trusted forest-wide (NTAuth + Root store)
certutil -viewstore "ldap:///CN=Certification Authorities,CN=Public Key Services,CN=Services,CN=Configuration,DC=corp,DC=example,DC=com?cACertificate"

Checklist of expected results:

7. CRL lifetimes, delta CRLs, and offline root renewal

The single most important operational fact:

When the root CRL expires, every certificate in the hierarchy fails revocation checking, which breaks chain validation everywhere — even though the certs themselves are still valid. This is the classic 2 a.m. forest-wide TLS outage.

CRL overlap (CRLOverlapPeriod) is your safety margin: it makes the CA publish the next CRL before the current one expires, so there is always a fresh CRL live. On the issuing CA the 1-week base + 1-day delta + 12-hour overlap means clients refresh constantly and a brief CA outage is invisible.

The offline root needs a recurring, calendared procedure because it is powered off. Roughly every ~5 months (well inside the 26-week window), boot it and republish:

# OFFLINE ROOT — recurring CRL renewal (do NOT renew the CA cert, just the CRL)
certutil -crl
# Copy the regenerated .crl from C:\Windows\System32\CertSrv\CertEnroll
# to the IIS /cdp path, then shut the root back down.

When the issuing CA certificate approaches end of life (e.g., year 8 of 10), renew it from the offline root. Renew with the same key only if the key is still strong and uncompromised; otherwise renew with a new key (this rolls the CA’s CDP/AIA %4 suffix and starts a fresh CRL):

# On issuing CA: generate a renewal request
certreq -new -policyserver * RenewalRequest.inf C:\IssuingCA01-renew.req
# (or use certsrv.msc -> All Tasks -> Renew CA Certificate)
# Submit to offline root exactly as in step 4, reinstall, restart certsvc.

8. Backup and disaster recovery

A CA without a tested restore plan is a liability. Back up two distinct things: the CA database + logs and the private key (the latter only matters for the software KSP; HSM keys live in the HSM).

# Full CA backup: database + private key, password-protected PFX for the key
Backup-CARoleService -Path "D:\CABackup\IssuingCA01\$(Get-Date -Format yyyyMMdd)" -Password (Read-Host -AsSecureString)

# Export the registry config that ties CDP/AIA/periods together (NOT covered by Backup-CARoleService)
reg export "HKLM\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration" "D:\CABackup\IssuingCA01\CertSvc-config.reg" /y

Why both: Backup-CARoleService captures the database and the key, but not the CA registry (your carefully tuned CDP/AIA URLs and CRL periods). Restore without the registry export and you are back to broken default URLs.

Restore path on a rebuilt host (same CA name and key):

# After reinstalling the ADCS role on the replacement server:
Restore-CARoleService -Path "D:\CABackup\IssuingCA01\20260608" -Password (Read-Host -AsSecureString) -Force
reg import "D:\CABackup\IssuingCA01\CertSvc-config.reg"
Restart-Service certsvc

For the offline root, the disaster plan is simpler and stricter: the root’s PFX (exported during build) lives in a physical safe, split with split-knowledge/dual-control if your policy requires it. Store the root certificate’s thumbprint and serial in your runbook so you can detect substitution. The root machine itself is disposable — what matters is the key material and the CAPolicy.inf.

Enterprise scenario

A platform team running a 12,000-seat Windows estate built exactly this two-tier hierarchy, with CDP/AIA published only to LDAP and a local file path — the default the wizard produced. It worked in the lab and in production for almost a year.

Then the security team rolled out an always-on VPN with strict split tunneling, and onboarded a fleet of macOS and Linux build agents that needed to validate the corporate TLS chain. Those non-Windows hosts had no LDAP path to AD, and the Windows VPN clients, before authenticating, also could not reach a domain controller to fetch the CRL over LDAP. Result: intermittent “revocation server offline” failures during VPN connect and outright chain-validation failures on the build agents. The certs were fine; the revocation information was unreachable.

The constraint: they could not reissue 12,000 certificates, and the issued certs already baked the LDAP CDP into their CRL Distribution Points extension. You cannot retroactively change the CDP inside a certificate that is already issued.

The fix was twofold. First, they added an HTTP CDP/AIA to the issuing CA so that newly issued and renewed certs carried a universally reachable URL, and stood up a hardened reverse proxy at pki.corp.example.com serving the .crl/.crt files from the CertEnroll directory. Second — the part that fixed the existing certs — they kept the LDAP CDP resolvable from anywhere by publishing the CRL to the HTTP path and letting autoenrollment reissue certs over the next renewal window, while configuring the VPN to allow the small pki.corp.example.com HTTP range pre-auth. Within one renewal cycle, every cert carried both an LDAP and an HTTP CDP.

The registry change that prevented a recurrence — HTTP listed first so non-AD clients hit it without an LDAP timeout — looked like this:

# HTTP CDP listed first (flag 6 = CDP ext + freshest CRL); LDAP retained for AD-joined hosts
certutil -setreg CA\CRLPublicationURLs "65:C:\Windows\system32\CertSrv\CertEnroll\%3%8%9.crl\n6:http://pki.corp.example.com/cdp/%3%8%9.crl\n79:ldap:///CN=%7%8,CN=%2,CN=CDP,CN=Public Key Services,CN=Services,%6%10"
certutil -setreg CA\CACertPublicationURLs "3:C:\Windows\system32\CertSrv\CertEnroll\%1_%3%4.crt\n2:http://pki.corp.example.com/cdp/%1_%3%4.crt\n3:ldap:///CN=%7,CN=AIA,CN=Public Key Services,CN=Services,%6%11"
Restart-Service certsvc
certutil -crl

The lesson generalizes: the CDP/AIA URLs baked into a certificate are immutable for that certificate’s life. Decide on HTTP (always reachable) versus LDAP (AD-only) on day one, list HTTP first, and you avoid a painful reissuance project later.

Final checklist

windows-serverad-cspkicertificatessecurity

Comments

Keep Reading