Code-signing a Windows app with Azure Trusted Signing, end to end

~/articles/code-signing-windows-app-azure-trusted-signing-end-end

I had a freshly published single-file .NET desktop .exe that ran fine and looked the part — and tripped SmartScreen's "Windows protected your PC / unknown publisher" wall the moment anyone but me tried to open it. This is the walk from that state to a signed, timestamped binary whose Properties → Digital Signatures shows a real organisation name, using Azure Trusted Signing rather than the old buy-an-EV-cert-on-a-USB-token route.

Why Trusted Signing instead of a classic cert

The traditional path was: buy a code-signing certificate (EV, for any hope of instant SmartScreen reputation), receive it on a FIPS hardware token, and then fight your CI to reach a USB dongle it can't see. The private key was long-lived and yours to guard.

Trusted Signing inverts that. You never hold a private key. Microsoft keeps the keys in its own HSMs and mints a short-lived end-entity certificate (~72 hours) per signing request, chained to a Microsoft CA that's already in the Windows Trusted Root Program. Your binary carries that signature plus an RFC3161 timestamp. The subject name on the cert comes from an identity validation you complete once. It's a monthly Azure line item rather than an annual cert purchase, and there's no hardware anywhere in the loop.

The tradeoff: you're fully dependent on an Azure service and its RBAC at sign time, and — as of this writing — the tooling is young (more on that below).

The setup chain

Four things have to exist, in order, and it's easy to think you're done one step too early:

  1. A Trusted Signing account (Microsoft.CodeSigning resource) in a region. The region matters — it fixes your signing endpoint (https://<region>.codesigning.azure.net/).
  2. An identity validation. This is the one that takes real-world time; it's what puts your legal org name on every cert. When people say "our cert got validated," this is usually what they mean.
  3. A certificate profile. This is the piece that actually issues signing certs from the validated identity — and it's a separate resource from the identity. A validated identity with no profile lists "No certificate profiles available", which reads like a failure but just means you haven't done step 3.
    • Profile type: Public Trust for publicly distributed software. (Private Trust and Test exist for internal/dev use where users manually trust your root.)
    • Program type: None. The "Windows endpoint security platform" option is a separate Microsoft enrolment for AV/EDR vendors, not general apps.
  4. An RBAC role assignment — see the next section, because this is the step that quietly breaks the first sign.

The gotcha: a valid cert is not permission to sign

You can have a completed identity, a provisioned Public Trust profile, and still get a 403 on your first sign. Signing is gated by Azure RBAC, separately from the cert being valid. The identity you authenticate as (your az login user, or a CI service principal) needs the "Trusted Signing Certificate Profile Signer" role on the signing account.

Assign it under the account's Access control (IAM), then wait a few minutes for propagation. You can confirm before you ever try to sign:

az role assignment list `
  --assignee (az ad signed-in-user show --query id -o tsv) `
  --scope "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CodeSigning/codeSigningAccounts/<account>" `
  -o table

If that returns no row naming the signer role, that's your 403 waiting to happen.

Signing: the sign CLI and its sharp edges

Microsoft's sign global tool drives Trusted Signing and handles timestamping. Two edges cost me a run:

It's prerelease-only on nuget.org. dotnet tool install --global sign reports the package "is not found in NuGet feeds" — because there's no stable version, only betas. You need --prerelease:

dotnet tool install --global sign --prerelease

Its install dir isn't on PATH in the current shell. Global tools land in %USERPROFILE%\.dotnet\tools, which a fresh install doesn't add to the running session's PATH. So immediately after installing, invoking sign fails with "not recognized" (and PowerShell unhelpfully suggests .\sign, pointing at your working directory instead). Prepend the tools dir yourself before calling it.

The actual sign call is unremarkable once the tool exists — endpoint, account, profile; auth comes from DefaultAzureCredential, i.e. your az login (or AZURE_TENANT_ID/AZURE_CLIENT_ID/AZURE_CLIENT_SECRET in CI):

sign code trusted-signing publish/App.exe `
  --trusted-signing-endpoint https://<region>.codesigning.azure.net/ `
  --trusted-signing-account <account> `
  --trusted-signing-certificate-profile <profile> `
  --description "App" --description-url "https://example.com"

Keep signing out of the dev build

I deliberately did not fold this into the normal build. Signing needs network, Azure auth, and a release intent — none of which belong on every dotnet build during development. It lives in its own sign.ps1 that:

  1. refuses to run if publish/App.exe doesn't exist (so it can't "sign" a stale or missing artifact),
  2. ensures the sign tool (with the two fixes above),
  3. signs,
  4. verifies.

The dev inner loop stays fast and offline; signing is a discrete, deliberate step against a published artifact.

Verify — and why the timestamp is the point

Two independent checks:

signtool verify /pa /v publish/App.exe          # chains to a trusted root under the default policy
(Get-AuthenticodeSignature publish/App.exe).Status   # want: Valid

The RFC3161 timestamp is not a nicety — it's what makes a 72-hour cert usable at all. The countersignature records that the file was signed while the cert was valid. Without it, your signature would go invalid three days later when the short-lived cert expired. With it, verifiers accept the signature indefinitely, because the timestamp authority vouches for when it happened. Short-lived certs and timestamping are a package deal.

What I'd do differently / honest limits

  • Signing ≠ instant trust. SmartScreen still accrues reputation per publisher over download volume. A standard (non-EV) Trusted Signing cert builds that reputation over time; it does not buy the day-one clean pass an EV cert historically did. Signed-but-new can still warn.
  • The tooling is young. A 0.9.x-beta CLI as the blessed path means flag names and behaviour can shift; pin a version if you automate this.
  • Region lock-in at the endpoint. The signing endpoint is tied to the account's region; get it wrong and you'll chase auth errors that are really "wrong URL."
  • n=1. One app, one machine, interactive az login. A CI service-principal setup adds its own RBAC and secret-management wrinkles I haven't stress-tested here.

The end state is worth it: a binary that names a real, verified organisation in every place Windows surfaces publisher identity — with no hardware token and no long-lived private key to lose.


Links: dotnet/sign · Azure Trusted Signing docs