How To

Active Directory Certificate Services on Windows Server 2025

Most Windows domains run one enterprise root CA that issues everything, so the key that anchors all trust sits on an online server any admin can reach. This Active Directory Certificate Services step by step guide splits that job in two: an offline root that signs one subordinate, and an enterprise issuing CA that handles autoenrollment and HTTP revocation.

Original content from computingforgeeks.com - post 180497

You will install AD CS on Windows Server 2025 with PowerShell and publish the CRL and AIA over HTTP, so Linux clients can check revocation too. Then you issue a certificate to nginx on Ubuntu 26.04, revoke it, and make OpenSSL refuse it. Every step below was run in October 2026 on Windows Server 2025 (build 26100.32230) and Ubuntu 26.04 with the root CA’s network adapter disconnected the whole time, so nothing here quietly depends on the root being reachable.

Two tiers and what each CA is allowed to touch

The root CA’s key is the one thing an attacker wants most, because anything it signs is trusted by every domain member. A two-tier hierarchy keeps that key on a standalone server that never joins the domain and never gets a network link. It signs the issuing CA’s certificate and its own CRL, then it gets switched off. The issuing CA runs as an enterprise CA on a domain member, reads templates from Active Directory and handles every request. If the issuing CA is ever compromised, you revoke it from the root and stand up a new one; the root and every client’s trust in it survive.

Two-tier AD CS PKI architecture diagram: offline ROOTCA01 signs Example-Issuing-CA01 on DC01; SRV01 autoenrolls over RPC and web-lab01 fetches CRL and AIA over HTTP 80

Only one port serves revocation and AIA data in this design: HTTP 80 on pki.corp.example.com, which serves the CA certificates and CRLs. Domain members also reach the issuing CA over RPC to enroll, and that traffic never leaves the domain network. Plain HTTP is deliberate. Microsoft’s CAPolicy.inf documentation states that CRL distribution points do not support HTTPS URLs, and a client that had to validate a TLS certificate before it could fetch the CRL that validates TLS certificates would loop forever.

The choice most readers are actually making is between that design and a single enterprise root CA. The threats each one stops and lets through:

ThreatSingle enterprise root CATwo-tier (offline root + enterprise subordinate)
Domain admin or DC compromiseRoot key stolen, whole PKI must be rebuilt and redistributedIssuing CA key at risk; revoke it from the root, root trust stays
Ransomware on the CA hostTrust anchor lost with the hostRoot is powered off and unreachable
Rogue certificate issued from a bad templateRevoke the cert; CA key still trustedSame, plus the option to retire the issuing CA
Expired root CRLNot an issue, CA is always onlineEvery chain fails until someone boots the root and runs certutil -crl
Servers to runOneTwo, one of them kept offline

The fourth row is the cost of the extra tier and the part teams forget. The root’s CRL in this guide is valid for 26 weeks, so someone has to boot the root CA, publish a new CRL and copy it to the web server twice a year. We would put that date in a shared calendar the day the build finishes, with two owners on it. The ceremony is more than certutil -crl on the root: the new Example-Root-CA.crl has to be copied to C:\pki and published again with certutil -dspublish -f ... ROOTCA01, or clients keep fetching the old one.

Lab layout and prerequisites

We use the names and addresses below in every command, so substitute your own consistently. CA names have no spaces on purpose: Windows writes the CA name into CRL and certificate file names, and a space becomes %20 in every CDP and AIA URL that Linux tools then have to fetch.

HostRoleAddress
ROOTCA01Standalone root CA Example-Root-CA, workgroup, no networknone
DC01AD DS and DNS for corp.example.com, enterprise subordinate CA Example-Issuing-CA01, IIS10.0.1.10
SRV01Domain member that autoenrolls10.0.1.20
web-lab01Ubuntu 26.04 LTS with nginx10.0.1.30

Running the issuing CA on the domain controller keeps the lab to three Windows machines. In production, put the issuing CA on its own member server. A CA on a DC means anyone with local admin on the CA is effectively a domain admin, and you cannot demote the DC without first removing the CA role. Before you start, check that these conditions hold:

  • The forest exists and DC01 answers DNS for corp.example.com (the AD DS setup with PowerShell gets you there).
  • ROOTCA01 runs Windows Server 2025, is not domain-joined and has its network adapter disconnected.
  • You hold Enterprise Admins on DC01; installing an enterprise CA writes to the forest Configuration partition.
  • A writable USB stick (or a second virtual disk) is ready to carry files to and from the root; the commands below call it E:\.
  • SRV01 has its own machine SID; clones of an un-sysprepped template cannot join a domain whose DC came from the same image.

The last item bit our own Proxmox template on this build, and the Windows Server template guide covers running sysprep so each clone gets a unique SID. We assume an elevated PowerShell session for every Windows command in this guide.

The offline standalone root CA

The root’s settings have to be right before the CA role is installed, because the root certificate is signed once and its extensions never change afterwards. A CAPolicy.inf file in the Windows directory is read during installation and controls the key length used at renewal, the root’s own CRL period and, most importantly, whether the root certificate carries CDP and AIA extensions at all.

Check the clock before anything else. A server with no network link never syncs time, and our ROOTCA01 clone booted with its clock exactly three hours fast because the virtual RTC held local time. Every certificate it signed was dated in the future, and DC01 rejected the chain as CERT_TRUST_IS_NOT_TIME_VALID until we rebuilt the root. Compare the root’s UTC time against a trusted clock and correct it first:

[DateTime]::UtcNow.ToString('u')
Set-Date -Adjust (New-TimeSpan -Hours -3)

The second line is what our clone needed; use whatever offset your comparison shows, or none. Then rename the server, since the CA’s configuration string includes the host name and cannot be changed after the role is installed:

Rename-Computer -NewName ROOTCA01 -Restart

After the reboot, create the policy file:

notepad C:\Windows\CAPolicy.inf

Paste the following and save it as ANSI text:

[Version]
Signature="$Windows NT$"

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

[CRLDistributionPoint]

[AuthorityInformationAccess]

The two empty sections at the bottom are the important lines. Microsoft documents that empty [CRLDistributionPoint] and [AuthorityInformationAccess] sections leave those extensions out of the root certificate, which is what you want. Nothing can check a root’s revocation against a CRL the root itself signs. CRLDeltaPeriodUnits=0 turns off delta CRLs on a CA that is offline anyway, and AlternateSignatureAlgorithm=0 keeps the plain PKCS#1 v1.5 signature that OpenSSL, Java and network appliances all read. LoadDefaultTemplates does nothing on a standalone CA, and it stays in the file only so the same template can be reused.

Install the role and configure the CA as a standalone root with a 4096-bit key and a 20-year lifetime. The long life is deliberate, because renewing a root means redistributing it to every client, so you want that to happen once a career, not once a hardware cycle:

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

The feature install took several minutes on our 2-vCPU VM, and the CA setup returns ErrorId : 0 with an empty ErrorString when it worked. The RSA#Microsoft Software Key Storage Provider string selects a CNG provider, which is the modern default and the family Microsoft requires for its newer algorithms such as ML-DSA. If you own an HSM, its provider name goes here instead. A software key on an offline, powered-off box is the reasonable floor for a lab or a small shop, not the ceiling.

Root CDP and AIA for the certificates it signs

The root certificate has no CDP, but the subordinate certificate it signs must point clients at the root’s CRL and certificate, or chain building and revocation checks fail on every client. Clear the defaults (they include LDAP and file:// paths that only make sense for an online enterprise CA) and point both extensions at the HTTP site that DC01 will host:

Get-CACrlDistributionPoint | ForEach-Object { Remove-CACrlDistributionPoint $_.Uri -Force }
Get-CAAuthorityInformationAccess | ForEach-Object { Remove-CAAuthorityInformationAccess $_.Uri -Force }
Add-CACrlDistributionPoint -Uri "C:\Windows\System32\CertSrv\CertEnroll\%3%8%9.crl" -PublishToServer -Force
Add-CACrlDistributionPoint -Uri "http://pki.corp.example.com/pki/%3%8%9.crl" -AddToCertificateCdp -Force
Add-CAAuthorityInformationAccess -Uri "http://pki.corp.example.com/pki/%1_%3%4.crt" -AddToCertificateAia -Force

The percent tokens are expanded by the CA when it writes each URL: %1 is the server’s DNS name, %3 the CA name, %4 the renewal suffix, %8 the CRL name suffix and %9 the delta marker. Keep %4 and %8 even though they are empty today, because after the first CA certificate renewal they stop the old and new files from overwriting each other. Removing every default AIA entry also removes the local CertEnroll entry, so after a renewal you copy the new .crt to the web folder by hand.

Now set how long the root signs subordinates for and how long its CRL lives. DSConfigDN tells the standalone root where the forest’s Configuration partition is. Nothing here uses LDAP URLs, so it only matters if you add an LDAP CDP later, but it costs nothing to set now:

certutil -setreg CA\ValidityPeriodUnits 10
certutil -setreg CA\ValidityPeriod "Years"
certutil -setreg CA\CRLPeriodUnits 26
certutil -setreg CA\CRLPeriod "Weeks"
certutil -setreg CA\CRLOverlapPeriodUnits 12
certutil -setreg CA\CRLOverlapPeriod "Hours"
certutil -setreg CA\DSConfigDN "CN=Configuration,DC=corp,DC=example,DC=com"
certutil -setreg CA\AuditFilter 127
auditpol /set /subcategory:"Certification Services" /success:enable /failure:enable
Restart-Service certsvc

The -setreg output prints old and new values, which doubles as a check that CAPolicy.inf was read: CRLPeriodUnits already showed 1a (26) before we touched it, while ValidityPeriodUnits moved from the default 1 to a (10). Ten years for the subordinate keeps it inside the root’s 20, which matters because a certificate can never outlive its issuer. AuditFilter 127 plus auditpol sends every issuance and revocation to the Security log, the only record you will have of a ceremony on a machine that runs twice a year.

Wait for the service before publishing the CRL. When we rebuilt the root, a certutil -crl run straight after the restart failed with 0x800706ba (WIN32: 1722 RPC_S_SERVER_UNAVAILABLE) because certsvc was still starting:

(Get-Service certsvc).WaitForStatus('Running','00:01:00'); Start-Sleep 10
certutil -crl
Get-ChildItem C:\Windows\System32\CertSrv\CertEnroll | Format-Table Name,Length -AutoSize

Exactly two files should be there, the root certificate and its CRL.

CertUtil: -CRL command completed successfully.

Name                         Length
----                         ------
Example-Root-CA.crl             712
ROOTCA01_Example-Root-CA.crt   1299

Copy both to the transfer media (Copy-Item C:\Windows\System32\CertSrv\CertEnroll\* E:\). We read the root back with OpenSSL before going further, and it showed what the policy file promised: a 4096-bit SHA-256 root valid until October 2046, CA:TRUE, and no CDP or AIA extension at all. Its CRL’s next update came out at 26 weeks plus the 12-hour overlap, in May 2027. Leave the root powered on for now, since it still has to sign the subordinate request.

An HTTP publication point on DC01

Every certificate the hierarchy issues will carry http://pki.corp.example.com/pki/ in its CDP and AIA, so that name has to resolve and serve files before the issuing CA starts. We use a CNAME so the files can move to a dedicated web server later without reissuing anything. The second record gives the Ubuntu web server a name in AD DNS, because Ubuntu does not register itself there and the TLS checks later connect by that name.

Create both records on DC01:

Add-DnsServerResourceRecordCName -ZoneName corp.example.com -Name pki -HostNameAlias dc01.corp.example.com
Add-DnsServerResourceRecordA -ZoneName corp.example.com -Name web-lab01 -IPv4Address 10.0.1.30

Install IIS and map a pki virtual directory to a local folder. The IIS install guide covers the role in more depth; here you only need the default site:

Install-WindowsFeature Web-Server -IncludeManagementTools
New-Item -Path C:\pki -ItemType Directory
New-WebVirtualDirectory -Site "Default Web Site" -Name pki -PhysicalPath C:\pki
& "$env:windir\system32\inetsrv\appcmd.exe" set config "Default Web Site/pki" -section:system.webServer/security/requestFiltering -allowDoubleEscaping:true

Do not skip the last line. Delta CRL file names end in a plus sign (Example-Issuing-CA01+.crl), and IIS request filtering rejects such URLs as double escaped. We dropped a test file named test+.crl into C:\pki and requested it before and after the change:

--- before allowDoubleEscaping
HTTP/1.1 404 Not Found
Content-Length: 4914
Server: Microsoft-IIS/10.0

Applied configuration changes to section "system.webServer/security/requestFiltering" for "MACHINE/WEBROOT/APPHOST/Default Web Site/pki" at configuration commit path "MACHINE/WEBROOT/APPHOST/Default Web Site/pki"
--- after allowDoubleEscaping
HTTP/1.1 200 OK
Content-Length: 11
Content-Type: application/pkix-crl
Server: Microsoft-IIS/10.0

That 404 is what every client would get for the delta CRL, and Windows reports it as the revocation server being offline even though the base CRL downloads fine. Next, copy the root certificate from the transfer media into the web folder and publish it to Active Directory, which is what makes every domain member trust the new root through Group Policy:

Copy-Item E:\ROOTCA01_Example-Root-CA.crt, E:\Example-Root-CA.crl C:\pki\
certutil -dspublish -f C:\pki\ROOTCA01_Example-Root-CA.crt RootCA
certutil -dspublish -f C:\pki\Example-Root-CA.crl ROOTCA01
certutil -addstore -f root C:\pki\ROOTCA01_Example-Root-CA.crt
certutil -addstore -f root C:\pki\Example-Root-CA.crl

Run these as an Enterprise Admin. The RootCA keyword writes the certificate into both the Certification Authorities and AIA containers, and ROOTCA01 names the CDP container the CRL is filed under:

ldap:///CN=Example-Root-CA,CN=Certification Authorities,CN=Public Key Services,CN=Services,CN=Configuration,DC=corp,DC=example,DC=com?cACertificate
Certificate added to DS store.
ldap:///CN=Example-Root-CA,CN=AIA,CN=Public Key Services,CN=Services,CN=Configuration,DC=corp,DC=example,DC=com?cACertificate
Certificate added to DS store.
CertUtil: -dsPublish command completed successfully.
ldap:///CN=Example-Root-CA,CN=ROOTCA01,CN=CDP,CN=Public Key Services,CN=Services,CN=Configuration,DC=corp,DC=example,DC=com?certificateRevocationList
Base CRL added to DS store.
CertUtil: -dsPublish command completed successfully.

Check that both files are now served before going further.

curl.exe -sI http://pki.corp.example.com/pki/Example-Root-CA.crl
curl.exe -sI http://pki.corp.example.com/pki/ROOTCA01_Example-Root-CA.crt

Ours came back HTTP/1.1 200 OK with Content-Type: application/pkix-crl for the CRL and application/x-x509-ca-cert for the certificate. A 404 here means the issuing CA will fail its revocation check in the next section, so fix the path or the virtual directory now.

The enterprise subordinate CA signed by the root

The issuing CA gets its own CAPolicy.inf. The setting that matters here is LoadDefaultTemplates=0. Without it, the CA starts publishing the default templates (User, Computer, EFS and others) the moment it comes up, and domain users and computers can enroll for them before you have decided which ones the domain should trust.

notepad C:\Windows\CAPolicy.inf

Add this content on DC01.

[Version]
Signature="$Windows NT$"

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

Install the role and generate the request, again as an Enterprise Admin, because an enterprise CA registers itself in the forest Configuration partition. With no -ParentCA, the cmdlet writes a request file instead of contacting a parent, which is exactly what an offline root needs:

Install-WindowsFeature ADCS-Cert-Authority -IncludeManagementTools
Install-AdcsCertificationAuthority -CAType EnterpriseSubordinateCA `
  -CACommonName "Example-Issuing-CA01" `
  -CryptoProviderName "RSA#Microsoft Software Key Storage Provider" `
  -KeyLength 2048 -HashAlgorithmName SHA256 `
  -OutputCertRequestFile C:\pki\Example-Issuing-CA01.req -Force

There is no -ValidityPeriod here, since Microsoft’s Install-AdcsCertificationAuthority reference says a subordinate’s lifetime comes from its parent. The cmdlet does not report success. It returns error 398, and that is the expected result.

ErrorId     : 398
ErrorString : The Active Directory Certificate Services installation is incomplete. To complete the installation, use the request file "C:\pki\Example-Issuing-CA01.req" to obtain a certificate from
              the parent CA. Then, use the Certification Authority snap-in to install the certificate. To complete this procedure, right-click the node with the name of the CA, and then click Install
              CA Certificate. The operation completed successfully. 0x0 (WIN32: 0)

certsvc stays stopped until its certificate is installed. Carry Example-Issuing-CA01.req to ROOTCA01 on the transfer media and submit it. A standalone CA puts every request in the pending queue for a human to approve, which is the control you want on a root, and -q keeps certreq from opening dialogs:

certreq -q -submit -config "ROOTCA01\Example-Root-CA" E:\Example-Issuing-CA01.req

The root’s own certificate holds RequestId 1, so on a fresh root the subordinate request is number 2:

RequestId: 2
RequestId: "2"
Certificate request is pending: Taken Under Submission (0)

Approve it and retrieve the signed certificate. Use the RequestId your own submission printed if it differs:

certutil -resubmit 2
certreq -q -retrieve -config "ROOTCA01\Example-Root-CA" 2 E:\Example-Issuing-CA01.crt

The root reports the issuance and who approved it.

Certificate issued.
CertUtil: -resubmit command completed successfully.
RequestId: 2
RequestId: "2"
Certificate retrieved(Issued) Issued  Resubmitted by WORKGROUP\ROOTCA01$

The signed certificate carries exactly the two root URLs and nothing else: a CDP of http://pki.corp.example.com/pki/Example-Root-CA.crl and an AIA of http://pki.corp.example.com/pki/ROOTCA01_Example-Root-CA.crt, valid for ten years. Back on DC01, install it and start the service. It has to go onto the same machine that generated the request, because the matching private key never left DC01’s key store:

certutil -installcert E:\Example-Issuing-CA01.crt
Start-Service certsvc

With the root CRL already reachable over HTTP, this returned CertUtil: -installCert command completed successfully. and certsvc went to Running. Our first attempt, made before the root CRL was in C:\pki, is in the errors section at the end. Once it is running, shut ROOTCA01 down and lock the media away.

CRL and AIA over HTTP for the issuing CA

The issuing CA’s defaults point clients at LDAP and a file:// share. Windows domain members can read LDAP, but Linux servers, Java runtimes, load balancers and phones cannot, so their revocation checks fail on every certificate you issue them. Replace the defaults with HTTP and have the CA write each new base and delta CRL straight into the IIS folder:

Get-CACrlDistributionPoint | ForEach-Object { Remove-CACrlDistributionPoint $_.Uri -Force }
Get-CAAuthorityInformationAccess | ForEach-Object { Remove-CAAuthorityInformationAccess $_.Uri -Force }
Add-CACrlDistributionPoint -Uri "C:\Windows\System32\CertSrv\CertEnroll\%3%8%9.crl" -PublishToServer -PublishDeltaToServer -Force
Add-CACrlDistributionPoint -Uri "C:\pki\%3%8%9.crl" -PublishToServer -PublishDeltaToServer -Force
Add-CACrlDistributionPoint -Uri "http://pki.corp.example.com/pki/%3%8%9.crl" -AddToCertificateCdp -AddToFreshestCrl -Force
Add-CAAuthorityInformationAccess -Uri "http://pki.corp.example.com/pki/%1_%3%4.crt" -AddToCertificateAia -Force

Then set the CRL lifetimes. A short base CRL plus a daily delta means a revoked certificate stops working within a day on Windows instead of within weeks. The -setreg output showed that an enterprise CA on Server 2025 already defaults to a one-week base CRL, a one-day delta and two-year issued certificates, so the lines below mostly pin those values explicitly and add the overlap and auditing:

certutil -setreg CA\CRLPeriodUnits 1
certutil -setreg CA\CRLPeriod "Weeks"
certutil -setreg CA\CRLDeltaPeriodUnits 1
certutil -setreg CA\CRLDeltaPeriod "Days"
certutil -setreg CA\CRLOverlapPeriodUnits 12
certutil -setreg CA\CRLOverlapPeriod "Hours"
certutil -setreg CA\ValidityPeriodUnits 2
certutil -setreg CA\ValidityPeriod "Years"
certutil -setreg CA\AuditFilter 127
auditpol /set /subcategory:"Certification Services" /success:enable /failure:enable
Restart-Service certsvc
(Get-Service certsvc).WaitForStatus('Running','00:01:00'); Start-Sleep 10
certutil -crl
Copy-Item "C:\Windows\System32\CertSrv\CertEnroll\DC01.corp.example.com_Example-Issuing-CA01.crt" C:\pki\
Remove-Item C:\pki\Example-Issuing-CA01.req

ValidityPeriodUnits 2 caps any certificate this CA issues at two years, whatever a template asks for. The copy puts the issuing CA’s own certificate where its AIA URL says it is, because the CA publishes CRLs to C:\pki automatically but never its certificate, and the last line removes the old request from a folder anyone can read over HTTP. Check that C:\pki now holds what clients will ask for:

  • Example-Issuing-CA01.crl is present (608 bytes on ours).
  • Example-Issuing-CA01+.crl, the delta, is present (545 bytes).
  • Both root files and DC01.corp.example.com_Example-Issuing-CA01.crt sit next to them.
  • No .req, .csr or .cer files are left behind.

The web.config in the folder is the one appcmd wrote for the double escaping setting, so leave it alone.

Prove the chain resolves from the network side by letting certutil fetch every AIA and CDP URL in the issuing CA’s certificate:

certutil -verify -urlfetch C:\pki\DC01.corp.example.com_Example-Issuing-CA01.crt

These are the lines that matter in the output. Each URL is followed by Verified, and the revocation line sits just before the final success message:

  Verified "Certificate (0)" Time: 0 fb7634fd30ae0bac11656006543197b633d733b8
    [0.0] http://pki.corp.example.com/pki/ROOTCA01_Example-Root-CA.crt
  Verified "Base CRL (03)" Time: 0 563d4ee3cf0d7c34a52af04d28880f17c08a8512
    [0.0] http://pki.corp.example.com/pki/Example-Root-CA.crl
Verified Issuance Policies: None
Verified Application Policies: All
Leaf certificate revocation check passed
CertUtil: -verify command completed successfully.

Any line that says Failed instead names the exact URL a client could not fetch. pkiview.msc shows the same check as a tree with a status per location, which is handy when an auditor asks for proof.

Templates the issuing CA may hand out

Because LoadDefaultTemplates=0 left the CA with nothing to issue (Get-CATemplate returned an empty list on ours), every template is now an explicit decision. Two are enough here. The built-in Web Server template takes the subject name from the request, which suits a Linux CSR, and only Domain Admins and Enterprise Admins can enroll for it by default. Publish it by its short name, not its display name:

Add-CATemplate -Name WebServer -Force
Get-CATemplate

For machine certificates that renew themselves, never edit a built-in template. The built-in Computer template is a version 1 template, and version 1 templates cannot autoenroll, so you need a duplicate. In certtmpl.msc that is right-click Computer, Duplicate Template, then grant Domain Computers Read, Enroll and Autoenroll on the Security tab. We did the same thing in PowerShell so the step is repeatable, copying the Computer template’s settings into a new version 2 template called ExampleComputerTLS:

$cfg = (Get-ADRootDSE).configurationNamingContext
$tplBase = "CN=Certificate Templates,CN=Public Key Services,CN=Services,$cfg"
$src = Get-ADObject "CN=Machine,$tplBase" -Properties flags,pKIDefaultKeySpec,pKIKeyUsage,pKIMaxIssuingDepth,pKICriticalExtensions,pKIExtendedKeyUsage,pKIExpirationPeriod,pKIOverlapPeriod,pKIDefaultCSPs,msPKI-Certificate-Name-Flag
$oidBase = (Get-ADObject "CN=OID,CN=Public Key Services,CN=Services,$cfg" -Properties msPKI-Cert-Template-OID).'msPKI-Cert-Template-OID'
$oid = "$oidBase.$(Get-Random -Minimum 1000000 -Maximum 9999999).$(Get-Random -Minimum 1000000 -Maximum 9999999)"
New-ADObject -Name 'Example-OID-ExampleComputerTLS' -Type msPKI-Enterprise-Oid -Path "CN=OID,CN=Public Key Services,CN=Services,$cfg" -OtherAttributes @{
  'msPKI-Cert-Template-OID'=$oid; 'displayName'='Example Computer TLS'; 'flags'=1 }
$attrs = @{
  'displayName'='Example Computer TLS'
  'flags'=[int]((($src.flags) -band (-bnot 0x10000)) -bor 0x20000)
  'revision'=100
  'pKIDefaultKeySpec'=$src.pKIDefaultKeySpec
  'pKIKeyUsage'=$src.pKIKeyUsage
  'pKIMaxIssuingDepth'=$src.pKIMaxIssuingDepth
  'pKICriticalExtensions'=[string[]]$src.pKICriticalExtensions
  'pKIExtendedKeyUsage'=[string[]]$src.pKIExtendedKeyUsage
  'pKIExpirationPeriod'=$src.pKIExpirationPeriod
  'pKIOverlapPeriod'=$src.pKIOverlapPeriod
  'pKIDefaultCSPs'=[string[]]$src.pKIDefaultCSPs
  'msPKI-Template-Schema-Version'=2
  'msPKI-Template-Minor-Revision'=1
  'msPKI-RA-Signature'=0
  'msPKI-Minimal-Key-Size'=2048
  'msPKI-Cert-Template-OID'=$oid
  'msPKI-Certificate-Application-Policy'=[string[]]$src.pKIExtendedKeyUsage
  'msPKI-Enrollment-Flag'=0x20
  'msPKI-Private-Key-Flag'=0
  'msPKI-Certificate-Name-Flag'=$src.'msPKI-Certificate-Name-Flag'
}
New-ADObject -Name ExampleComputerTLS -Type pKICertificateTemplate -Path $tplBase -OtherAttributes $attrs
$dn = "AD:CN=ExampleComputerTLS,$tplBase"
$acl = Get-Acl $dn
$sid = (Get-ADGroup 'Domain Computers').SID
$enroll = [guid]'0e10c968-78fb-11d2-90d4-00c04f79dc55'
$autoenroll = [guid]'a05b8cc2-17bc-4802-a710-e7c15ab866a2'
$acl.AddAccessRule((New-Object System.DirectoryServices.ActiveDirectoryAccessRule $sid,'ReadProperty','Allow'))
$acl.AddAccessRule((New-Object System.DirectoryServices.ActiveDirectoryAccessRule $sid,'ExtendedRight','Allow',$enroll))
$acl.AddAccessRule((New-Object System.DirectoryServices.ActiveDirectoryAccessRule $sid,'ExtendedRight','Allow',$autoenroll))
Set-Acl $dn $acl

Two values carry the security decisions. msPKI-Enrollment-Flag 0x20 is the autoenrollment flag, and msPKI-Certificate-Name-Flag is copied from Computer, which builds the subject from the machine’s own DNS name in Active Directory so a computer cannot request a name it does not own. A template that lets the requester supply the subject on a group as broad as Domain Computers is the classic path to a certificate that impersonates another host. Confirm what landed in AD:

certutil -v -template ExampleComputerTLS | Select-String 'Allow Enroll|Allow Auto-Enroll|Server Authentication|Client Authentication'

Both usages and both permissions came through.

    1.3.6.1.5.5.7.3.1 Server Authentication
    1.3.6.1.5.5.7.3.2 Client Authentication
    Allow Enroll	CORP\Domain Computers
    Allow Auto-Enroll	CORP\Domain Computers

Publishing the new template right away failed, even after restarting certsvc, with Add-CATemplate : The "ExampleComputerTLS" template does not exist in the domain. The CA reads templates from a local cache, not straight from AD. Refreshing that cache fixed it:

certutil -pulse
Add-CATemplate -Name ExampleComputerTLS -Force
Get-CATemplate

Get-CATemplate then listed ExampleComputerTLS and WebServer and nothing else. Under the KB5014754 timeline, domain controllers moved to full enforcement of strong certificate mapping with the February 11, 2025 security update, and the registry switch back to compatibility mode stopped being supported with the September 9, 2025 update. Certificates from a template that builds the subject from Active Directory carry the SID extension that mapping needs. Certificates whose subject is supplied in the request do not, which is one more reason to keep the Web Server template away from anything used for logon.

Autoenrollment by Group Policy

Template permissions say who may autoenroll, and a GPO tells the clients to actually do it. In the Group Policy Management Editor the setting lives under Computer Configuration, Policies, Windows Settings, Security Settings, Public Key Policies, as the Certificate Services Client - Auto-Enrollment policy, with Configuration Model set to Enabled and both check boxes ticked. That dialog writes a single registry value, so we created the GPO and set it from PowerShell:

New-GPO -Name "PKI Autoenrollment" | New-GPLink -Target "DC=corp,DC=example,DC=com"
Set-GPRegistryValue -Name "PKI Autoenrollment" -Key "HKLM\SOFTWARE\Policies\Microsoft\Cryptography\AutoEnrollment" -ValueName AEPolicy -Type DWord -Value 7

To be sure AEPolicy 7 means what the dialog means, read the GPO back the way GPMC renders it:

[xml]$r = Get-GPOReport -Name "PKI Autoenrollment" -ReportType Xml
$r.OuterXml -replace '.*(<q\d+:AutoEnrollmentSettings.*?</q\d+:AutoEnrollmentSettings>).*','$1'

GPMC files it under Public Key policies with automatic enrollment on and both options set, which are the “Renew expired certificates, update pending certificates, and remove revoked certificates” and “Update certificates that use certificate templates” boxes:

<q1:AutoEnrollmentSettings><q1:EnrollCertificatesAutomatically>true</q1:EnrollCertificatesAutomatically><q1:Options><q1:RenewUpdateRevoke>true</q1:RenewUpdateRevoke><q1:UpdateTemplates>true</q1:UpdateTemplates></q1:Options><q1:ExpiryNotification>false</q1:ExpiryNotification><q1:NotifyPercent><q1:Present>false</q1:Present><q1:Value>10</q1:Value></q1:NotifyPercent></q1:AutoEnrollmentSettings>

The RenewUpdateRevoke option is the one that removes revoked certificates from clients, so revocation actually propagates instead of sitting in a CRL that nobody on the machine reads. On SRV01, pull the policy and trigger an enrollment pass instead of waiting for the eight-hour refresh:

gpupdate /force
certutil -pulse
Get-ChildItem Cert:\LocalMachine\My | Format-List Subject, Issuer, NotAfter

SRV01 did not even need the pulse. Its first boot after joining the domain applied the GPO and enrolled on its own, before anyone logged on. To capture a clean run we emptied Cert:\LocalMachine\My, ran the three commands above and read the new certificate’s details:

Subject   : CN=SRV01.corp.example.com
Issuer    : CN=Example-Issuing-CA01, DC=corp, DC=example, DC=com
NotBefore : 10/30/2026 5:16:24 PM
NotAfter  : 10/30/2027 5:16:24 PM

Template=Example Computer TLS(1.3.6.1.4.1.311.21.8.13532010.45880.4017563.8120205.7744221.91.3206129.1976473), Major Version Number=100, Minor Version Number=1
Server Authentication, Client Authentication
DNS Name=SRV01.corp.example.com

The subject and SAN came from SRV01’s own DNS name in Active Directory, the lifetime is the one year the Computer template carries, and both usages are there. The certificate also carries the SID security extension (OID 1.3.6.1.4.1.311.25.2) that strong certificate mapping looks for. If the store stays empty, the Application log under source CertificateServicesClient-AutoEnrollment says why, and the usual reason is the Autoenroll permission missing from the template.

A certificate for nginx on Ubuntu

Linux servers are where a Windows PKI usually breaks, because they cannot read LDAP and do not trust your root until you tell them to. The private key should be generated on the server that uses it and never leave it, so start on web-lab01 with a key and a CSR that carries the names clients will connect to:

sudo mkdir -p /etc/nginx/tls
cd /etc/nginx/tls
sudo openssl req -new -newkey rsa:2048 -nodes -keyout web-lab01.key -out web-lab01.csr -subj "/CN=web-lab01.corp.example.com" -addext "subjectAltName=DNS:web-lab01.corp.example.com,DNS:web-lab01"
sudo chmod 600 web-lab01.key

Check the request before it goes anywhere, because the CA signs whatever names the Web Server template is handed:

openssl req -in /etc/nginx/tls/web-lab01.csr -noout -subject
openssl req -in /etc/nginx/tls/web-lab01.csr -noout -text | grep -A1 "Subject Alternative"

On our Ubuntu 26.04.1 VM with OpenSSL 3.5.5, the subject and both SAN entries came back as requested:

OpenSSL CSR with subject and SAN for web-lab01 on Ubuntu 26.04 before AD CS signing

Modern clients ignore the CN and match only the SAN, so a CSR without the subjectAltName line produces a certificate browsers and curl reject as a name mismatch. Copy web-lab01.csr to DC01 (any route works, and scp from DC01 is simplest where the OpenSSH client is installed) and submit it against the Web Server template as a Domain Admin:

certreq -q -submit -attrib "CertificateTemplate:WebServer" -config "DC01.corp.example.com\Example-Issuing-CA01" C:\pki\web-lab01.csr C:\pki\web-lab01.cer
certutil -view -restrict "CommonName=web-lab01.corp.example.com" -out "RequestID,SerialNumber,NotAfter,CertificateTemplate"

An enterprise CA issues straight away, with no pending step. Note the serial number, because the revocation test later uses it:

RequestId: 4
RequestId: "4"
Certificate retrieved(Issued) Issued

Row 1:
  Issued Request ID: 0x4
  Serial Number: "2c000000042c82d1120c6afc46000000000004"
  Certificate Expiration Date: 10/29/2028 5:00 PM
  Certificate Template: "WebServer"

Copy web-lab01.cer back to /etc/nginx/tls/web-lab01.crt on web-lab01 (certreq writes PEM, so no conversion is needed), and remove the CSR and certificate from C:\pki, since that folder is world-readable over HTTP. Next, make the server resolve the PKI names and trust the root. Pointing the resolver at DC01 works now that both records exist; hosts entries work for a single box:

echo "10.0.1.10 pki.corp.example.com" | sudo tee -a /etc/hosts
echo "10.0.1.30 web-lab01.corp.example.com web-lab01" | sudo tee -a /etc/hosts
cd /etc/nginx/tls
sudo curl -so root.der http://pki.corp.example.com/pki/ROOTCA01_Example-Root-CA.crt
sudo curl -so issuing.der http://pki.corp.example.com/pki/DC01.corp.example.com_Example-Issuing-CA01.crt
sudo openssl x509 -inform DER -in root.der -out root.pem
sudo openssl x509 -inform DER -in issuing.der -out issuing.pem
sudo cp root.pem /usr/local/share/ca-certificates/example-root-ca.crt
sudo update-ca-certificates

update-ca-certificates should report 1 added, 0 removed; done. The CA certificates come off IIS in DER form, which is why each download is converted with -inform DER. Only the root goes into the system trust store; nginx sends the issuing CA certificate as part of the chain, so build the full chain file next:

cat web-lab01.crt issuing.pem | sudo tee web-lab01-fullchain.pem > /dev/null
openssl x509 -in web-lab01.crt -noout -subject -issuer -dates -ext subjectAltName,crlDistributionPoints,authorityInfoAccess,extendedKeyUsage

The issued certificate carries the SANs from the CSR, the HTTP CDP and AIA from the issuing CA, and server authentication only, valid for the two years the CA allows.

subject=CN=web-lab01.corp.example.com
issuer=DC=com, DC=example, DC=corp, CN=Example-Issuing-CA01
notBefore=Oct 30 17:00:18 2026 GMT
notAfter=Oct 29 17:00:18 2028 GMT
X509v3 Subject Alternative Name:
    DNS:web-lab01.corp.example.com, DNS:web-lab01
X509v3 CRL Distribution Points:
    Full Name:
      URI:http://pki.corp.example.com/pki/Example-Issuing-CA01.crl

Authority Information Access:
    CA Issuers - URI:http://pki.corp.example.com/pki/DC01.corp.example.com_Example-Issuing-CA01.crt
X509v3 Extended Key Usage:
    TLS Web Server Authentication

Create the server block. It points at the full chain rather than the leaf alone, because nginx sends exactly the file it is given, and a Linux client that lacks the issuing CA certificate does not go looking for it:

sudo vim /etc/nginx/sites-available/web-lab01

Add this configuration:

server {
    listen 443 ssl;
    server_name web-lab01.corp.example.com web-lab01;

    ssl_certificate     /etc/nginx/tls/web-lab01-fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/web-lab01.key;
    ssl_protocols       TLSv1.2 TLSv1.3;

    root /var/www/html;
}

Enable the site, test the configuration and reload. nginx -t refuses a key that does not match the certificate, which catches a mixed-up file before clients do:

sudo ln -s /etc/nginx/sites-available/web-lab01 /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Verify the handshake from the server itself. -verify_return_error makes s_client exit non-zero on any chain problem instead of printing a warning and carrying on:

openssl s_client -connect web-lab01.corp.example.com:443 -servername web-lab01.corp.example.com -verify_return_error < /dev/null 2>/dev/null | grep -E "Verify return code|subject=|issuer="

The chain validated against the system store, now that it holds our root.

subject=CN=web-lab01.corp.example.com
issuer=DC=com, DC=example, DC=corp, CN=Example-Issuing-CA01
Verify return code: 0 (ok)

Plain curl https://web-lab01.corp.example.com also connected without -k; it returned 403 only because the server block points at an empty web root. If s_client shows 20 (unable to get local issuer certificate), nginx is serving the leaf without issuing.pem. Windows clients often still connect in that case because they fetch the missing certificate from the AIA URL, which hides the bug until a Linux client hits it. The OpenSSL cheat sheet has more s_client checks for chains and protocols.

Revocation that Linux clients actually enforce

A CRL nobody checks is decoration. OpenSSL does not check revocation unless asked, and -crl_check_all wants a CRL for every certificate in the chain, including the root. The root has no CDP by design, so -crl_download has nothing to fetch for it, and our first run stopped there:

openssl verify -crl_check_all -crl_download -CAfile root.pem -untrusted issuing.pem web-lab01.crt

That failure looks like a broken CDP, but it is only the missing root CRL.

CN=Example-Root-CA
error 3 at 2 depth lookup: unable to get certificate CRL
error web-lab01.crt: verification failed

Download the root CRL once, pass it in, and let OpenSSL fetch the issuing CA’s CRL from the leaf’s CDP:

sudo curl -so root.crl http://pki.corp.example.com/pki/Example-Root-CA.crl
sudo openssl crl -inform DER -in root.crl -out root-crl.pem
openssl verify -crl_check_all -crl_download -CRLfile root-crl.pem -CAfile root.pem -untrusted issuing.pem web-lab01.crt

This time it returned web-lab01.crt: OK. Because the issuing CA’s CRL was downloaded from the URL inside the leaf, a pass also proves that URL is reachable over HTTP from a non-Windows host. Now revoke the certificate on DC01 with reason code 1 (key compromise), using the serial from the issuance step, and publish a new CRL instead of waiting for the next scheduled one:

certutil -revoke 2c000000042c82d1120c6afc46000000000004 1
certutil -crl

certutil names the reason, so you can confirm the right code went in.

Revoking "2c000000042c82d1120c6afc46000000000004" -- Reason: Key Compromise
CertUtil: -revoke command completed successfully.
CertUtil: -CRL command completed successfully.

Run the same openssl verify on web-lab01 again, then read the freshly published CRL to see what it was refused against:

openssl verify -crl_check_all -crl_download -CRLfile root-crl.pem -CAfile root.pem -untrusted issuing.pem web-lab01.crt
curl -s http://pki.corp.example.com/pki/Example-Issuing-CA01.crl | openssl crl -inform DER -noout -text | grep -E "Serial Number|Reason|Key Compromise|Next Update|Last Update"

Both results from our run are in the capture below:

OpenSSL on Ubuntu 26.04 rejecting an AD CS certificate as revoked after certutil -revoke, with the CRL entry and Key Compromise reason

Error 23 is OpenSSL’s certificate revoked code, and the CRL shows the serial with its Key Compromise reason and a next update about seven and a half days out, which is the one-week period plus the 12-hour overlap. Windows clients pick up a revocation sooner through the daily delta CRL. OpenSSL only reads delta CRLs when you add -use_deltas, so a revocation between base CRLs can stay invisible to Linux tools for up to a week with these periods. If that window is too wide for your servers, we would shorten the base CRL period rather than rely on deltas.

Revocation and enrollment errors from the lab

Every error below came up while building this lab, with the output as Windows or OpenSSL printed it.

CRYPT_E_REVOCATION_OFFLINE (0x80092013) before the root CRL is published

Our first certutil -installcert ran before Example-Root-CA.crl was in C:\pki, and launched from a non-interactive session it simply hung with no output until we killed it. certutil -verify -urlfetch on the subordinate certificate showed why:

  Failed "CDP" Time: 0 (null)
    Error retrieving URL: Not found (404). 0x80190194 (-2145844844 HTTP_E_STATUS_NOT_FOUND)
    http://pki.corp.example.com/pki/Example-Root-CA.crl
ERROR: Verifying leaf certificate revocation status returned The revocation function was unable to check revocation because the revocation server was offline. 0x80092013 (-2146885613 CRYPT_E_REVOCATION_OFFLINE)

“Offline” here means a 404, not a dead server. Copy the root CRL into C:\pki, confirm it with curl.exe -sI, and run the install again. The same code appears on every client once the root CRL expires, which is the six-monthly ceremony from the threat table.

CERT_TRUST_IS_NOT_TIME_VALID on a brand-new chain

The same verify run also reported CERT_TRUST_IS_NOT_TIME_VALID (0x1) on both certificates. The root’s clock was three hours ahead, so its certificate’s notBefore sat in DC01’s future. Windows backdates a new CA certificate by about ten minutes, which hides small drift but not hours of it. The fix is the clock check at the start of the root section; a root whose dates are wrong has to be reinstalled, because the dates are signed into the certificate.

0x800706ba RPC_S_SERVER_UNAVAILABLE from certutil -crl

certutil talks to certsvc over RPC, and right after Restart-Service certsvc the service is not listening yet. Wait for it with (Get-Service certsvc).WaitForStatus('Running') and a few seconds of sleep, as the commands above do.

“The template does not exist in the domain” from Add-CATemplate

A template created a moment ago exists in AD but not yet in the machine’s certificate template cache, which is what Add-CATemplate checks. certutil -pulse refreshed the cache on DC01 and the same command then succeeded.

HTTP 404 on the delta CRL

IIS rejects any file name with a plus sign until allowDoubleEscaping is set on the pki virtual directory; the before and after responses are in the publication point section. The base CRL downloads, the delta does not, and Windows clients report the revocation server offline.

OpenSSL error 3: unable to get certificate CRL

-crl_check_all with -crl_download cannot find a CRL for a root that has no CDP. Pass the root CRL with -CRLfile, or use -crl_check to check the leaf only.

PKI audit before the root goes back in the safe

Run through this list after the build and again after every root CRL ceremony. Each item is a condition that must hold, not a task to remember:

  1. ROOTCA01 is powered off, has no network adapter, and its disk or VM image sits in storage only two named people can reach.
  2. The root’s clock matched UTC when it last signed anything.
  3. The root certificate has no CDP or AIA extension.
  4. The root CRL’s Next Update date is in your calendar, at least four weeks ahead of expiry.
  5. certutil -verify -urlfetch on a fresh leaf certificate shows Verified for every URL.
  6. The delta CRL URL returns 200, not 404.
  7. Get-CATemplate lists only the templates you chose to publish.
  8. No published template lets a broad group supply its own subject name.
  9. openssl verify -crl_check_all -crl_download with the root CRL passed in succeeds on a Linux host.
  10. The CA database and private key backup (Backup-CARoleService) is stored offline and has been restored once on a test machine.

Items 4 and 10 are the ones that fail silently. A root CRL that lapses takes down TLS and smart card logon for the whole domain on the same morning, and an untested CA backup is discovered to be useless on the day the issuing CA’s disk dies. For a broader reading list on running Windows infrastructure, the Windows Server administration books roundup covers PKI alongside AD and Group Policy, and the AD DS install guide is the starting point if the domain itself does not exist yet.

Keep reading

Create a Bootable Windows 11 USB and Install Windows 11 Windows Create a Bootable Windows 11 USB and Install Windows 11 Set Up a Samba Share on Windows 11 and Access It from Linux Networking Set Up a Samba Share on Windows 11 and Access It from Linux Windows 11 Installation on KVM with Virt-Manager KVM Windows 11 Installation on KVM with Virt-Manager Configure NTP Server on Windows Server 2025 / 2022 (w32tm Guide) Windows Configure NTP Server on Windows Server 2025 / 2022 (w32tm Guide) Windows 11 Keyboard Shortcuts Cheat Sheet (Printable PDF) Cheat Sheets Windows 11 Keyboard Shortcuts Cheat Sheet (Printable PDF) Automate Windows Server Administration with Ansible Automation Automate Windows Server Administration with Ansible

Leave a Comment

Press ESC to close