Skip to main content
Version: 0.18

Worked example: Windows/IIS with CNG-native key custody

The video uses a private test CA; choose the issuer and DNS provider appropriate to your environment.

What this page is​

One Windows certificate: issued against a real ACME CA, enrolled into CNG, bound to an IIS site, and left running so it renews itself. Use this after Install an agent (Windows) and the windows-iis target when you want those settings in one place.

How Windows protects the private key
Key custody model

On a windows-iis target, TokenTimer never generates a private key file, .pfx, or any other on-disk key material. The agent asks Windows CNG to generate the key and hold it (certreq -new), submits the resulting CSR to your ACME CA, then asks CNG to complete enrollment (certreq -accept) once the CA returns the signed certificate. The key exists only inside the CNG key storage provider for the whole of its life. This is deliberate: it is the same custody model IIS admins already expect from a certificate "generated in the store," not a foreign file-based workflow bolted onto Windows.

Prerequisites​

  • A TokenTimer instance with CLM enabled (CERTOPS_ENABLED=true, plus CERTOPS_SIGNING_ENCRYPTION_KEY and CERTOPS_REGISTRATION_ENCRYPTION_KEY set) and the CertOps maintenance worker (worker-certops) running. See Enable CLM.
  • A Windows Server host, 64-bit, with the IIS role installed and at least one site with an HTTPS binding already present on the port you intend to manage (the agent binds an existing listener to a certificate; it does not create IIS sites). Supported on Windows Server 2025, Windows Server 2022, and Windows Server 2019; see Install an agent (Windows) - Requirements for the full supported-version note.
  • Node.js 22 or 24 installed on that host.
  • An ACME client the agent can drive: this walkthrough uses acme.sh (see the callout below for why, and what it takes to set up on Windows). certbot also works if you already run it elsewhere in your fleet, but is not packaged for Windows; see the note in Install an agent (Windows).
  • A DNS-01-capable domain (any provider from DNS-01 providers) - windows-iis, like every other target type, only supports DNS-01, not HTTP-01. See the architecture note below.
Why this example uses acme.sh and DNS-01
Why acme.sh, and why DNS-01

Two constraints shape this walkthrough:

  1. The agent's ACME adapter only speaks DNS-01. The bundled agent has no HTTP-01/webroot adapter. This IIS workflow uses DNS-01, as the Linux agent does. For certificates issued by your own ACME tooling, see ACME challenge selection.
  2. On Windows, the agent invokes Certbot's DNS hook as "<node.exe>" "<hookPath>" present|cleanup. Certbot runs --manual-auth-hook/--manual-cleanup-hook through a shell, and a bare .js path is not executable (no shebang, no useful file association). Both paths are quoted so spaces (for example Program Files) survive. This walkthrough uses acme.sh because Certbot dropped official Windows support in February 2024: its DNS hook is a shell function, not a subprocess launched by path.

Use the setup guide for the guided path; the manual job below is a reference. For configuration access errors or a private lab CA, see Windows maintenance.

Step 1 - Install the agent​

On Certificate Management, open Deploy an agent and create a bootstrap token first (it is shown exactly once). From an elevated PowerShell prompt on the Windows host:

.\scripts\install-agent.ps1 --api-url https://your-tokentimer.example --workspace-id <your workspace id>

Paste the bootstrap token at the prompt (or set $env:TOKENTIMER_AGENT_BOOTSTRAP_TOKEN for unattended installs). Confirm the service is running:

Get-Service TokenTimerAgent

See Install an agent (Windows) for the full flag reference and layout.

Step 2 - Set up the ACME client​

Install acme.sh outside any interactive-only path, and register the dns_certops.sh hook the agent ships (the Windows installer copies this into acme.sh's own dnsapi/ directory, so a fresh install needs nothing extra beyond having acme.sh itself present):

git clone https://github.com/acmesh-official/acme.sh C:\ProgramData\TokenTimerAgent\tools\acme.sh

Add an acmesh-signcsr command profile in config.json (Step 4 below shows the settings to merge). Any profile name works if it is identical in declaredCommandProfileNames, allowedCommands and the job's commandRef:

{
"declaredCommandProfileNames": ["acmesh-signcsr"],
"policy": {
"allowedCommands": {
"acmesh-signcsr": {
"argv": [
"C:\\Program Files\\Git\\bin\\bash.exe",
"C:\\ProgramData\\TokenTimerAgent\\tools\\acme.sh\\acme.sh"
]
}
}
}
}

acme.sh itself is a POSIX shell script; running it on Windows means invoking it through a bash you have installed (Git for Windows' bundled bash.exe). argv is the allowlisted executable chain plus fixed leading arguments only; the agent appends --signcsr, the CSR path, the DNS hook flags, --server <caEndpoint>, --dnssleep 0, and the output paths itself.

Step 3 - Store the DNS-01 provider credential​

Same shape as the Linux walkthrough, just at a Windows path. For example, for Cloudflare:

$credPath = "C:\ProgramData\TokenTimerAgent\state\dns-cloudflare-credentials.json"
[IO.File]::WriteAllText($credPath, '{ "apiToken": "<your scoped Cloudflare API token>" }', (New-Object Text.UTF8Encoding($false)))
icacls $credPath /inheritance:r /grant:r "SYSTEM:(F)" "*S-1-5-32-544:(F)"

*S-1-5-32-544 is the built-in Administrators group SID; adjust the second grant to the specific installing administrator account if you want a tighter ACL. The agent refuses a credential file with a looser ACL than this at startup, the same enforcement as on Linux, just expressed through icacls instead of POSIX mode bits.

Step 4 - Configure the agent​

Edit C:\ProgramData\TokenTimerAgent\state\config.json. Merge the settings below, replacing the example domain, workspace ID and DNS provider with your own:

Merge these settings into the existing file; this example deliberately omits the enrolled identity.

Keep the enrolled identity

Preserve agentId, signing pins and existing policy when merging these settings. Replacing the entire file with a fresh-host template can stop the service. See configuration maintenance if access or startup fails.

{
"serverUrl": "https://your-tokentimer.example",
"workspaceId": "<your workspace id>",
"declaredTargetSelectors": ["www.example.com"],
"declaredCommandProfileNames": ["acmesh-signcsr"],
"policy": {
"allowedCommands": {
"acmesh-signcsr": {
"argv": [
"C:\\Program Files\\Git\\bin\\bash.exe",
"C:\\ProgramData\\TokenTimerAgent\\tools\\acme.sh\\acme.sh"
]
}
},
"allowedCaEndpoints": ["https://acme-v02.api.letsencrypt.org/directory"],
"allowedDnsZones": ["example.com"],
"allowedDnsProviders": ["cloudflare"],
"allowedVerifyHosts": ["www.example.com"]
},
"dnsProviders": {
"cloudflare": { "credentialsFile": "C:\\ProgramData\\TokenTimerAgent\\state\\dns-cloudflare-credentials.json" },
"zoneProviderMap": { "example.com": "cloudflare" }
},
"execution": {
"enabled": true,
"dryRun": true
}
}

Check these three settings before continuing:

  1. zoneProviderMap is required whenever DNS zone-to-provider routing is not otherwise unambiguous. Without it, the DNS hook fails with dns: could not discover a managed DNS zone for "<domain>" even though the provider credential and allowedDnsZones/allowedDnsProviders are all correctly set. Map every zone you issue for to the provider that hosts it.
  2. declaredCommandProfileNames and declaredTargetSelectors are sent again on heartbeat by a 0.18.0 or later agent, so adding or removing a profile and restarting the agent updates the control plane. An older agent sends the profile names only at registration. Selectors are sent again on heartbeat when the list is not empty. Add a DNS name, restart the agent, and the next heartbeat replaces the stored list. An empty list does not clear it. An older agent that does not send selectors on heartbeat still publishes them only at registration.
  3. Keep local dryRun: true for the signed no-op smoke check. Execution must be enabled to claim that test. It verifies signed dispatch/reporting, not the later ACME, DNS or reload permissions.

Restart after editing:

Restart-Service TokenTimerAgent

Before issuance, complete the signed no-op smoke check. Then set local execution.dryRun to false in the existing config and restart only TokenTimerAgent. Review the actual issuance destinations and allowlists before that transition.

Step 5 - Create the job​

From Certificate Management, use Create manual job (or POST /api/v1/workspaces/{id}/certops/jobs) with a windows-iis target:

API callers must follow session authentication and CSRF. The following is the JSON request body, also usable in the manual job form:

{
"operation": "issue",
"idempotencyKey": "iis-worked-example-1",
"payload": {
"target": {
"type": "windows-iis",
"reference": "www.example.com",
"store": "My",
"binding": { "site": "Default Web Site", "port": 443, "sniHost": "www.example.com" }
},
"sans": ["www.example.com"],
"commandRef": "acmesh-signcsr",
"acmeKind": "acme.sh",
"caEndpoint": "https://acme-v02.api.letsencrypt.org/directory",
"dnsZone": "example.com",
"dnsProvider": "cloudflare",
"verifyHost": "www.example.com",
"verifyPort": 443
}
}

store is almost always "My" (the machine's Personal store, LocalMachine\My). binding.site must match an existing IIS site name exactly, and binding.port an existing binding on that site; binding.sniHost, when set, binds via hostnameport= rather than the bare ipport=, so the certificate only serves that one hostname on a shared listener rather than every hostname on that IP:port, but a non-SNI (ipport=) binding on the same port, if one exists (wildcard or a specific IP), still takes precedence for clients connecting through that address, since this is http.sys's own dispatch rule rather than something either binding can override. A binding without sniHost is created as netsh http add sslcert ipport=0.0.0.0:<port>. Agent builds before 0.18.0 used ipport=*:<port>, which netsh rejects with "The parameter is incorrect". Watch the agent's own reported precedenceWarning after the deploy step below for a known conflict on this exact binding. See the full field reference in the windows-iis target.

Watch the agent claim and run the job by reading its log file under C:\ProgramData\TokenTimerAgent\state\.

Step 6 - Verify​

Also inspect the actual Certificates (Local Computer) and IIS Manager views and make a fresh independent TLS check. Match the served fingerprint and validity with the issued certificate; an HTTP response alone is insufficient.

On Certificate Management, open the job and confirm it reached succeeded. On the host, confirm the certificate landed in the store and IIS is actually serving it:

Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.DnsNameList.Unicode -contains "www.example.com" }
netsh http show sslcert hostnameport=www.example.com:443

Filter on .DnsNameList, not .Subject: an ACME-issued leaf is commonly SAN-only, with no Subject CN, so Where-Object { $_.Subject -like "*www.example.com*" } matches nothing even when the certificate is bound.

The netsh output's certificate hash should match the new certificate's thumbprint. On a non-English host the field labels are translated, but the hash is not. Make a fresh TLS connection with normal hostname/chain validation, then compare the served certificate with that installed identity:

$tcp = [System.Net.Sockets.TcpClient]::new('www.example.com', 443)
$tls = [System.Net.Security.SslStream]::new($tcp.GetStream(), $false)
try {
$tls.AuthenticateAsClient('www.example.com')
$served = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($tls.RemoteCertificate)
$served | Format-List Thumbprint, SerialNumber, NotBefore, NotAfter
} finally {
$tls.Dispose()
$tcp.Dispose()
}

The returned thumbprint must match the new IIS-bound certificate, and its serial/validity must match the issued result. A connection without this comparison can still be serving the old certificate.

The certificate now shows state: "auto" under its renewal object in the dashboard: TokenTimer derived a renewal profile automatically the moment the issue job succeeded, exactly as on Linux, but confirm the profile is enabled, an eligible agent is online, workspace policy allows execution and the maintenance worker is running. See Renewal checks.

What happens on renewal​

A renew job against a windows-iis target runs the same CNG-native path as issuance: a fresh ACME order and CSR, a new key generated and accepted directly inside CNG, a rebind of the same IIS site/port/SNI host to the new certificate (netsh http add sslcert, replacing the previous binding entry and keeping the binding TLS flags, such as disabled HTTP/2 or legacy TLS), then live TLS verification against the binding before the job reports success. No iisreset or blanket service reload happens; only that one binding changes.

The agent reads the existing binding from the http.sys configuration. Those reads require Windows PowerShell 5.1 (powershell.exe) and were tested with display languages en-US, de-DE, and fr-FR. A renewal keeps the binding TLS flags. If the binding holds a setting the agent cannot carry over to the new certificate, the renewal fails with UNSUPPORTED_BINDING_SETTINGS and the binding is left unchanged. The error names the setting; see Troubleshooting.

The certificate the rebind replaces is not deleted immediately. It is recorded in a restart-safe retention ledger (C:\ProgramData\TokenTimerAgent\state\windows-retention\<thumbprint>.json, one file per superseded certificate) and kept for a grace window before an eventual cleanup sweep removes both the certificate and its CNG key container. This is deliberate: if the new certificate turns out to be wrong, there is always a real previous certificate on disk in the store to fall back to, not just a hope that the CA will reissue quickly.

Troubleshooting​

Most of these show up against a self-hosted test CA. A public CA such as Let's Encrypt already has a trusted chain.

SymptomRoot causeFix
Job rejected with target_out_of_scope even though the agent is registered and heartbeatingdeclaredTargetSelectors did not include the job's target referenceAdd the exact hostname to declaredTargetSelectors and restart the agent. The next heartbeat publishes it. See the note in Step 4
DNS hook fails with dns: could not discover a managed DNS zone for "<domain>"No zoneProviderMap entry for the zoneAdd dnsProviders.zoneProviderMap.<zone>: "<provider>"; see Step 4
Job hangs in running for many minutes against an internal-only or otherwise not-publicly-resolvable test zone, using acme.shThe agent's own DNS hook already confirmed authoritative propagation, but acme.sh then ran its own separate, publicly-resolver-bound propagation recheck, which can never succeed for a zone with no public delegationThe agent passes --dnssleep 0 to acme.sh, which skips acme.sh's public-resolver recheck. The hook already confirmed authoritative propagation.
certreq -accept fails with CERT_E_CHAINING (0x800B010A) even though the ACME order and CSR both succeededThe CA trust anchor imported for the ACME endpoint's own TLS/HTTPS listener is not necessarily the same CA that signs the certificates it issues (true of most self-hosted test CAs; not applicable to a public CA like Let's Encrypt, whose issuing chain is already trusted by Windows)Import the CA that actually signs issued leaf certificates (check your CA's own docs for how to retrieve it) into Cert:\LocalMachine\Root (root) and Cert:\LocalMachine\CA (any intermediate), using LocalMachine, not CurrentUser, since the agent service runs as LocalSystem and only reads the machine-wide store
Downloading a private CA certificate fails TLS validation, or a script depends on -SkipCertificateCheckThe HTTPS transport is not trusted; Windows PowerShell 5.1 also lacks that switchObtain the CA certificate through a trusted CA-administration/export channel and independently compare its fingerprint. Configure the required transport trust before HTTPS retrieval; do not accept all certificates to bootstrap trust. See Private-CA trust paths
A certreq/certutil command run against a self-hosted CA (for example while looking up its exact config name for a manual submit/resubmit) hangs forever with no output and no errorcertreq -submit/certutil -config with no explicit -config "<host>\<CA name>" argument (or a bare -config -) opens an interactive CA-picker dialog; in any session with no interactive desktop (a remote script runner, a scheduled task, most CI/automation contexts) nothing can render or dismiss that dialog, so the process waits indefinitelyAlways pass an explicit -config "<host>\<CA name>" (find the exact name once, interactively, via certutil -ping on a real desktop session, then hardcode it in scripts); never use the bare -config - form outside an interactive session
Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Subject -like "*<domain>*" } in Step 6 returns nothing, even though the job reported succeeded and netsh http show sslcert shows the bindingACME-issued leaf certificates are commonly SAN-only, with the Subject field empty; .Subject never contains the domain at allFilter on .DnsNameList instead (Where-Object { $_.DnsNameList.Unicode -contains "<domain>" }), or look the certificate up directly by the thumbprint reported in netsh http show sslcert
Renewal fails with UNSUPPORTED_BINDING_SETTINGS and the binding is unchangedThe current binding has a setting the agent cannot carry over to the new certificate: an http.sys setting it does not recognize, or an option this host's netsh does not support (for example some newer per-connection flags on Windows Server 2019, where netsh reports success without creating the binding). The error detail names itRemove the named setting from the binding, or rebind that certificate manually with netsh http add sslcert, then retry. The agent never deletes a binding it cannot restore
Job fails with QUERY_FAILED before changing the binding, or discovery lists no certificates or bindingsThe agent reads the certificate store and http.sys bindings through Windows PowerShell 5.1 (powershell.exe), and that query failed, for example because PowerShell is missing or blocked for the agent's accountConfirm powershell.exe runs as the agent's account (LocalSystem by default) and that no policy blocks it, then retry. The query also works under Constrained Language Mode
renew job creation is refused, or the certificate never shows state: "auto" after a successful issuanceRenewal-profile auto-derivation failed silently; check the certificate's reconciliationReason fieldIf it reads renewal_profile_derivation_failed, check the CERTOPS_RENEWAL_PROFILE_DERIVATION_DECLINED audit event's derivationReason/detail fields (see Audit events); a missing store/binding.site on the job payload is the most common cause
Certificate deployed and bound, but nothing shows up correctly in the Certificates dashboardThe job never emitted the fingerprint/validity evidence the control plane needs to reconcile a windows-iis deploymentAfter a successful IIS bind the agent emits a validation.passed evidence item with the deployed fingerprint and validity. If the certificate stays at provisioning after succeeded, check that evidence item.