Worked example: Windows/IIS with CNG-native key custody
What this page is
Worked example: Cloudflare DNS-01 traces one certificate end to end on Linux. This page is the same idea for Windows: one certificate, issued for real against a real ACME CA, enrolled directly into the Windows certificate store through CNG (Cryptography API: Next Generation), bound to a real IIS site, and left running long enough to watch it renew itself automatically with no manual intervention. It was produced by running the whole flow against a real Windows Server host with IIS, an installed agent, and a real end-to-end ACME issuance, then writing down exactly what worked and what did not, and independently reproduced a second time end to end against a different Windows Server release (see the version note in Prerequisites) to catch anything accidentally coupled to one specific build.
Use this page when Install an agent (Windows) and the windows-iis target have told you the shape of each setting and you want to see them all fit together once, correctly, before doing it against your own host.
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 plan that includes CertOps agents.
- 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). Verified end to end 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+ 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.
- 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.
Two real constraints shaped this walkthrough, discovered while building it rather than assumed up front:
- The agent's ACME adapter only speaks DNS-01. There is no HTTP-01/webroot code path in the product at all, on any platform. If your plan was to serve the HTTP-01 challenge from IIS itself, that path does not exist yet; use a DNS-01 provider instead, the same as on Linux.
- certbot's Windows DNS hook had a real bug, now fixed. Certbot's
--manual-auth-hook/--manual-cleanup-hookneeds to invoke the agent's own hook script, but a bare.jspath is not directly executable on Windows (no shebang support). Current agent builds prefix the hook command withnode.exeand quote the path correctly onwin32; if you are on an older build and see the DNS hook simply fail to run at all on Windows, upgrade the agent. acme.sh does not have this problem (its DNS hook is a shell function, not a subprocess it execs by path), which is why this walkthrough defaults to it.
Step 1 - Install the agent
On Certificate operations, open Deploy an agent and create a bootstrap token first (it is shown exactly once). Copy the generated install command; it already has the correct --api-url for your instance. From an elevated PowerShell prompt on the Windows host:
.\install-agent.ps1 --api-url https://tokentimer.ch --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 automatically as of the current release, so a fresh install needs nothing extra here 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 full file):
{
"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 is the one this walkthrough verified). 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"
'{ "apiToken": "<your scoped Cloudflare API token>" }' | Set-Content -Path $credPath -Encoding utf8
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. This is the full config shape actually verified end to end (replace the domain, workspace id, and DNS zone/provider with your own):
{
"serverUrl": "https://tokentimer.ch",
"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": false
}
}
Registration writes agentId (and the config loader's other identity/pin state) back into this same config.json, merged with whatever was already there. If you manage this file with a tool that renders and overwrites the whole file from a template (rather than editing it in place), a render taken after the agent already registered will drop agentId from the on-disk file, and the next restart fails immediately with found a stored credential but no agentId in config.json; the config directory is inconsistent. Diff or re-read the file before overwriting it, or restrict automated config management to the fields that predate registration (serverUrl, policy, dnsProviders, execution, …) and leave agentId alone.
Three things here are Windows-specific, each of which produced a real failure while verifying this walkthrough:
zoneProviderMapis required whenever DNS zone-to-provider routing is not otherwise unambiguous. Without it, the DNS hook fails withdns: could not discover a managed DNS zone for "<domain>"even though the provider credential andallowedDnsZones/allowedDnsProvidersare all correctly set. Map every zone you issue for to the provider that hosts it.declaredTargetSelectorsanddeclaredCommandProfileNamesare read once, at registration, exactly as on Linux. Editing them later and restarting the agent does not retroactively change what the control plane recorded for this agent; a job that needs a capability added after the fact will not route to this agent until it is edited before that first registration, or you re-register with a new bootstrap token.- Both
execution.enabled: trueandexecution.dryRun: falseare required, exactly as on Linux. A fresh install ships withenabled: falseanddryRun: true.
Restart after editing:
Restart-Service TokenTimerAgent
Step 5 - Create the job
From Certificate operations, use Create manual job (or POST /api/v1/workspaces/{id}/certops/jobs) with a windows-iis target, the shape verified end to end:
{
"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. 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
On Certificate operations, 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: a real ACME-issued leaf certificate is commonly SAN-only, with no Subject CN set at all, so Where-Object { $_.Subject -like "*www.example.com*" } silently matches nothing even when the certificate is correctly issued and bound. This walkthrough's own first draft used the .Subject filter and it never matched anything real; it is called out again in the troubleshooting table below since it is an easy trap to fall back into.
The netsh output's certificate hash should match the new certificate's thumbprint. A real TLS handshake against the binding is the strongest confirmation:
$req = [System.Net.HttpWebRequest]::Create("https://www.example.com/")
$req.GetResponse() | Out-Null
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, and nothing further is needed for it to renew on its own within its renewal window.
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), 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 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.
This walkthrough verified the full unattended loop, not just one manually triggered renewal: after the first issuance, the real scheduler picked the certificate up on its own once it entered its renewal window, dispatched a real renew job with no operator action, and the agent executed it correctly, twice in a row, each time producing a new CNG key, a new IIS binding, and a new retention ledger entry for the certificate it replaced.
Troubleshooting: everything that actually went wrong
Every row below is a real failure hit while verifying this walkthrough end to end, in the order they were found. Most of these are one-time host-setup issues specific to standing up a test ACME CA; they will not recur against a real public CA like Let's Encrypt, but are included because the underlying mechanism (chain trust, DNS zone routing, ACME client quirks) applies regardless of which CA you point at.
| Symptom | Root cause | Fix |
|---|---|---|
Job rejected with target_out_of_scope even though the agent is registered and heartbeating | declaredTargetSelectors did not include the job's target reference | Add the exact hostname to declaredTargetSelectors before the agent's first registration; 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 zone | Add 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.sh | The 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 delegation | Fixed in current agent builds, which pass --dnssleep 0 to acme.sh (this skips acme.sh's redundant second check, not verification itself; the hook already did the real one). Upgrade the agent if you still see this |
certreq -accept fails with CERT_E_CHAINING (0x800B010A) even though the ACME order and CSR both succeeded | The 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) — LocalMachine, not CurrentUser — since the agent service runs as LocalSystem and only reads the machine-wide store |
Scripting a self-hosted CA's root/intermediate download over HTTPS with Invoke-WebRequest -SkipCertificateCheck throws A parameter cannot be found that matches parameter name 'SkipCertificateCheck' | -SkipCertificateCheck was only added in PowerShell 6+; Windows Server ships PowerShell 5.1 by default | On PowerShell 5.1, bypass certificate validation for that one call instead via [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } (reset it back to $null immediately after the call), or run the script under PowerShell 7 if you have it installed |
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 error | certreq -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 indefinitely | Always 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 binding | ACME-issued leaf certificates are commonly SAN-only, with the Subject field empty; .Subject never contains the domain at all | Filter on .DnsNameList instead (Where-Object { $_.DnsNameList.Unicode -contains "<domain>" }), or look the certificate up directly by the thumbprint reported in netsh http show sslcert |
renew job creation is refused, or the certificate never shows state: "auto" after a successful issuance | Renewal-profile auto-derivation failed silently; check the certificate's reconciliationReason field | If it reads renewal_profile_derivation_failed, this is a server-side bug in a specific release, not a host-configuration issue; contact support with the certificate id, which release you're on, and the exact reconciliationReason value |
| Certificate deployed and bound, but nothing shows up correctly in the Certificates dashboard | The job never emitted the fingerprint/validity evidence the control plane needs to reconcile a windows-iis deployment | Fixed in current agent builds, which emit a validation.passed evidence item with the deployed certificate's fingerprint and validity after a successful IIS bind. Upgrade the agent if a windows-iis certificate stays stuck at provisioning despite a job reporting succeeded |
Related
- Install an agent (Windows)
- The
windows-iistarget - Worked example: Cloudflare DNS-01 - the same walkthrough shape, on Linux with a file-based target.
- DNS-01 providers
- Automation and executors