Automatically Signing a Windows EXE with Azure Trusted Signing, dotnet sign, and GitHub Actions

If you have ever shipped a standalone Windows executable to end users, you already know the drill. Windows Defender SmartScreen flags the file with a blue warning screen, the user has to click through “More info” and then “Run anyway,” and half of them abandon the install right there. This happens because your executable has no reputation with Microsoft’s SmartScreen service, and reputation is built slowly through downloads and, more directly, through code signing.

Traditional code signing certificates solve this problem but bring their own headaches. You buy a certificate from a CA, store the private key somewhere safe (often a USB hardware token), renew it every year or two, and hope nobody loses the token. Azure Trusted Signing removes most of that friction by moving the certificate and the private key entirely into Azure, backed by an HSM, with no exportable key material for you to protect.

This walkthrough covers setting up Azure Trusted Signing end to end: registering the service, validating your identity, signing locally with the dotnet sign CLI, and then wiring the whole thing into a GitHub Actions release pipeline so every tagged release gets signed automatically. This is based on real-world setup notes from a working implementation, so the rough edges and gotchas are included, not just the happy path.

What Azure Trusted Signing actually gives you

Azure Trusted Signing is Microsoft’s cloud-based code signing service. It is worth understanding what changes compared to a conventional certificate before you commit to it, because the model is genuinely different, not just a different vendor for the same thing.

  • No hardware tokens: everything happens in the cloud, so there is no USB dongle to lose or plug into a CI runner.
  • Automatic certificate management: certificates are issued and renewed automatically behind the scenes.
  • GitHub Actions integration: an official action exists to sign artifacts as part of your CI/CD pipeline.
  • Reasonably affordable: roughly ten dollars a month for the Basic SKU on a small project, cheaper than a yearly EV certificate but still a recurring cost worth budgeting for.
  • Same trust chain as Microsoft’s own apps: your executable is signed under the same certificate authority Microsoft uses internally, which tends to help SmartScreen reputation build faster.

One trade-off to flag early: certificates issued by this service are short-lived, typically valid for around three days. That sounds alarming the first time you hear it, but it works fine in practice because of RFC 3161 timestamping, which we will get to later in this article. For now, just know that a short cert lifetime is by design, not a bug.

Prerequisites

Before you start, get these in place. Skipping ahead without them is the most common reason people get stuck halfway through.

  • An Azure subscription with permission to create resources and, later, assign roles.
  • Azure CLI installed and up to date.
  • Identity validation documents: a government-issued ID such as a driver’s license or passport for individual developers, or business registration documents if you are validating as an organization.
  • A Windows PC if you want to sign locally as a sanity check, though this is optional since you can sign purely through CI.
  • A GitHub repository if your end goal is automated signing through Actions.

Part 1: Setting up Azure Trusted Signing

The first step is registering the Microsoft.CodeSigning resource provider on your subscription. Providers in Azure are opt-in per subscription, and code signing is not registered by default, so this step is easy to miss the first time.

# Login to Azure
az login
 
# Register the Microsoft.CodeSigning resource provider
az provider register --namespace Microsoft.CodeSigning
 
# Wait for registration to complete (takes 2-3 minutes)
az provider show --namespace Microsoft.CodeSigning --query "registrationState"

Run the last command a few times until it returns “Registered”. Registration is asynchronous, so checking immediately after the register call will usually show “Registering” rather than a failure, just give it a couple of minutes.

Next, create the actual Trusted Signing account. You can do this from the Azure Portal by searching for “Trusted Signing Accounts” and filling in a subscription, resource group, account name, region, and the Basic SKU, or you can do it entirely from the CLI:

# Create a resource group
az group create --name MyAppSigning --location westus2
 
# Create the Trusted Signing account
az trustedsigning create \
  --resource-group MyAppSigning \
  --account-name myapp-signing \
  --location westus2 \
  --sku-name Basic

Note the region endpoint that corresponds to whichever location you picked, for example wus2 maps to https://wus2.codesigning.azure.net/ and East US maps to https://eus.codesigning.azure.net/. This endpoint is specific to your account’s region and you will need it in every signing command later, both locally and in CI. Getting this wrong is a common source of 403 errors later on, so write it down now.

Identity validation is the step that most people underestimate. Microsoft needs to confirm you are a real person or a real organization before you can sign anything, and this is a manual review process, not an instant API call. In the Portal, open your Trusted Signing Account, go to Identity validation, and choose either Individual (driver’s license or passport) or Organization (business registration documents).

For individual validation you upload a clear photo of your government ID, enter your legal name exactly as it appears on the ID, and submit. Approval typically takes one to three business days for individuals and three to five for organizations, though in practice this can be much faster or, depending on region and document quality, noticeably slower. You cannot sign anything until this approval comes through, so start this step first if you are working against a deadline.

Once identity validation is approved, create a certificate profile, which is the object that actually issues signing certificates against your validated identity. Give it a descriptive name, choose Public Trust as the profile type, and select Code Signing as the certificate type.

The Public Trust versus Private Trust distinction matters more than the naming suggests. Only Public Trust profiles are recognized by SmartScreen and general Windows trust chains. Private Trust is meant for internally distributed line-of-business applications where you control every machine the app runs on, and using it by mistake will leave you wondering why SmartScreen warnings never go away.

Verify everything is in place with a quick CLI check before moving on:

# List your Trusted Signing accounts
az trustedsigning show \
  --resource-group MyAppSigning \
  --account-name myapp-signing
 
# Should show status: "Succeeded"

Keep a note of five values before moving to the next section: the account name, the certificate profile name, the region endpoint URL, the subscription ID, and the resource group name. You will use all five repeatedly, both when signing locally and when configuring the GitHub Actions workflow.

Part 2: Signing locally first

You do not strictly need to sign locally before automating this in CI, but it is worth doing once. It lets you confirm the whole chain works, inspect the resulting certificate in Windows Explorer’s Properties dialog, and debug permission issues without burning CI minutes on every retry.

First, grant yourself the role needed to actually use the signing service. In the Portal, open your Trusted Signing Account, go to Access control (IAM), click Add then Add role assignment, and search for “Trusted Signing Certificate Profile Signer”. Searching for “code” will return nothing useful here, search for “Trusted” instead. Select your own account as the member and complete the assignment.

If you prefer the CLI, the equivalent is:

# Get your user object ID
$userId = az ad signed-in-user show --query id -o tsv
 
# Assign the role
az role assignment create \
  --role "Trusted Signing Certificate Profile Signer" \
  --assignee-object-id $userId \
  --scope /subscriptions/YOUR_SUBSCRIPTION_ID/resourceGroups/MyAppSigning/providers/Microsoft.CodeSigning/codeSigningAccounts/myapp-signing

Replace YOUR_SUBSCRIPTION_ID with your actual subscription ID before running this. Role assignment through the CLI can take a minute or two to propagate, so do not panic if a signing attempt fails immediately afterward, just retry after a short wait.

Next, log in with the correct scope. This step trips people up because Azure CLI’s default login does not request the codesigning scope, and Visual Studio’s cached credentials can interfere with device authentication for this specific service.

# Logout first to clear old tokens
az logout
 
# Login with codesigning scope
az login --use-device-code --scope "https://codesigning.azure.net/.default"

This prints a code and asks you to visit https://microsoft.com/devicelogin to enter it. Device code flow is more reliable here than the default browser-based login precisely because it sidesteps conflicts with other cached Azure credentials on the same machine, a real issue if you also use Visual Studio with an Azure account signed in.

With authentication sorted, install the dotnet sign tool. You can install it globally if you plan to sign multiple projects, or locally to a project folder if you would rather keep the tool scoped to one repository:

# Global install (recommended for regular use)
dotnet tool install --global --prerelease sign
sign --version
 
# OR local install (project-specific)
dotnet tool install --tool-path . --prerelease sign

Note that the sign tool is currently distributed as a prerelease package, so the –prerelease flag is required, dropping it will cause the install to fail with no matching package found.

Now sign the executable itself. The -b flag needs an absolute path to the base directory containing the files to sign, not a relative one, this is easy to get wrong if you are used to relative paths in most CLI tools:

cd C:\MyProject
 
.\sign.exe code trusted-signing `
  -b "C:\MyProject\publish" `
  -tse "https://wus2.codesigning.azure.net" `
  -tscp "MyAppProfile" `
  -tsa "myapp-signing" `
  *.exe `
  -v Information

Here -tse is the region endpoint you noted earlier, -tscp is your certificate profile name, and -tsa is your Trusted Signing account name. The *.exe pattern signs every executable in the base directory, which is convenient if your build produces more than one binary. A successful run prints something like “info: Signing MyApp.exe succeeded. Completed in 2743 ms.”

Confirm the signature took effect, either through PowerShell or the Windows Explorer UI:

# Check the signature
Get-AuthenticodeSignature ".\publish\MyApp.exe" | Format-List
 
# Look for:
# Status: Valid
# SignerCertificate: CN=Your Name, O=Your Name, ...
# TimeStamperCertificate: Should be present

You can also right-click the EXE, open Properties, and check the Digital Signatures tab. If everything worked you will see your name listed as the signer and a message confirming the signature is valid.

A few issues come up often enough to call out directly. A “Please run az login” error almost always means you logged in without the codesigning scope, log out and log back in with the –scope flag shown above. A 403 Forbidden error usually means one of three things: the endpoint does not match your account’s actual region, the account name has a typo (names are case-sensitive), or the role assignment has not propagated yet. And “User account does not exist in tenant” typically means Azure CLI picked up cached Visual Studio credentials instead of your device-code login, switching to device code flow resolves it.

Part 3: Automating signing with GitHub Actions

Manual signing is fine for testing but defeats the purpose if you still have to run a command by hand for every release. The real value here is wiring signing into a GitHub Actions workflow so a tagged commit produces a signed, ready-to-distribute executable with no manual step.

GitHub Actions needs its own identity, separate from your personal login, to authenticate against Azure and request a signature. This is a service principal, effectively a robot account scoped only to the signing permission it needs. Creating one requires Owner or User Access Administrator on the subscription, if you do not have that role, you will need to ask whoever manages the subscription to run this for you.

# Create service principal with signing permissions
az ad sp create-for-rbac \
  --name "MyAppGitHubActions" \
  --role "Trusted Signing Certificate Profile Signer" \
  --scopes /subscriptions/YOUR_SUBSCRIPTION_ID/resourceGroups/MyAppSigning/providers/Microsoft.CodeSigning/codeSigningAccounts/myapp-signing \
  --json-auth

This prints JSON containing a clientId, clientSecret, tenantId, and subscriptionId. Save all four values immediately, the clientSecret in particular cannot be retrieved again once this command finishes, if you lose it your only option is to generate a new one.

{
  "clientId": "12345678-1234-1234-1234-123456789abc",
  "clientSecret": "super-secret-value-abc123",
  "tenantId": "87654321-4321-4321-4321-cba987654321",
  "subscriptionId": "abcdef12-3456-7890-abcd-ef1234567890"
}

If the CLI approach is not available to you, the Azure Portal offers the same outcome through App registrations. Create a new registration, copy the Application (client) ID and Directory (tenant) ID, generate a new client secret under Certificates and secrets, and then separately grant that app registration the Trusted Signing Certificate Profile Signer role under Access control (IAM) on your Trusted Signing account.

Add all four values as encrypted GitHub repository secrets under Settings, then Secrets and variables, then Actions: AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID, and AZURE_SUBSCRIPTION_ID. These secrets are encrypted at rest and never appear in workflow logs, only your own workflow steps can reference them.

A reasonable question at this point is whether you could avoid storing a client secret entirely using federated credentials, since Azure does support OpenID Connect federation with GitHub Actions for managed identities. That is worth investigating as a follow-up hardening step once the basic secret-based flow works, since it removes a long-lived credential from your secret store, though it does add its own setup complexity around trust configuration between GitHub and Azure AD.

With secrets in place, add signing steps to your workflow file. Adjust the dotnet-version, target runtime, and paths to match your own project structure, the shape below is a representative example rather than a drop-in for every repository:

name: Build and Sign
 
on:
  push:
    tags:
      - 'v*'
  workflow_dispatch:
 
permissions:
  contents: write
 
jobs:
  build:
    runs-on: windows-latest
 
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
      with:
        fetch-depth: 0
 
    - name: Setup .NET
      uses: actions/setup-dotnet@v4
      with:
        dotnet-version: '10.0.x'
 
    - name: Restore dependencies
      run: dotnet restore MyApp/MyApp.csproj
 
    - name: Build
      run: |
        dotnet publish MyApp/MyApp.csproj `
          -c Release `
          -r win-x64 `
          --self-contained
 
    - name: Azure Login
      uses: azure/login@v2
      with:
        creds: '{"clientId":"${{ secrets.AZURE_CLIENT_ID }}","clientSecret":"${{ secrets.AZURE_CLIENT_SECRET }}","subscriptionId":"${{ secrets.AZURE_SUBSCRIPTION_ID }}","tenantId":"${{ secrets.AZURE_TENANT_ID }}"}'
 
    - name: Sign executables with Trusted Signing
      uses: azure/trusted-signing-action@v0
      with:
        azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
        azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
        azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }}
        endpoint: https://wus2.codesigning.azure.net/
        trusted-signing-account-name: myapp-signing
        certificate-profile-name: MyAppProfile
        files-folder: ${{ github.workspace }}\MyApp\bin\Release\net10.0-windows\win-x64\publish
        files-folder-filter: exe
        files-folder-recurse: true
        file-digest: SHA256
        timestamp-rfc3161: http://timestamp.acs.microsoft.com
        timestamp-digest: SHA256
 
    - name: Create Release
      if: startsWith(github.ref, 'refs/tags/')
      uses: softprops/action-gh-release@v2
      with:
        files: MyApp/bin/Release/net10.0-windows/win-x64/publish/MyApp.exe
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

The signing step itself is the azure/trusted-signing-action, which wraps the same underlying sign tool you used locally. The endpoint, account name, and certificate profile name all need to match exactly what you noted down in Part 1, case-sensitivity applies here too. The files-folder-filter and files-folder-recurse options let you sign every matching file in a build output tree in one pass rather than naming each executable individually, useful if your build produces multiple binaries or you are building for several target runtimes.

Test the workflow with a manual trigger before relying on tag pushes, since workflow_dispatch lets you run it repeatedly without creating a release each time:

# Trigger manually via GitHub CLI
gh workflow run build.yml
 
# Or use the GitHub web UI:
# Actions tab -> "Build and Sign" workflow -> "Run workflow" button

Once the manual run succeeds end to end, cut an actual release by tagging a commit and pushing the tag:

git status
git tag v1.0.0
git push origin v1.0.0

Watch the run from the CLI to catch failures quickly instead of refreshing the Actions tab in a browser:

gh run list --limit 5
gh run watch
gh run view --log

A healthy run shows Azure Login completing in about five seconds and the signing step completing in ten to thirty seconds depending on how many files you are signing. If the Create Release step is conditioned on a tag push, as in the workflow above, it will only fire for actual releases, not for manual test runs, which is exactly what you want during setup.

The most common CI-specific failures are worth knowing in advance. A 403 during signing in Actions almost always means the service principal, not your personal account, is missing the Trusted Signing Certificate Profile Signer role, check Access control (IAM) on the Trusted Signing account itself. “No files matched the pattern” usually means files-folder points somewhere your build did not actually publish to, add a debug step with Get-ChildItem -Recurse to see where your binaries really landed. And secret-related failures are almost always a typo in the secret name or a trailing space accidentally included when the secret was pasted in.

Understanding the three-day certificate

The short certificate lifetime deserves a proper explanation because it is the part of this system that feels most unusual coming from traditional code signing. Certificates issued by Azure Trusted Signing are valid for roughly three days, and Microsoft frames this as a security feature: if a certificate is ever compromised, the exposure window is small, and you never handle certificate files or passwords that could leak in the first place.

The obvious follow-up question is what happens to a signature after the three-day certificate expires. This is where RFC 3161 timestamping does the real work. When a file is signed, Azure issues a short-lived certificate, signs the file with it, and a timestamp authority separately records that the signing happened at a specific date and time. Even after the certificate itself expires, the signature stays valid because the timestamp proves the signing occurred while the certificate was still valid, which is exactly why both the local sign command and the GitHub Actions workflow above include a timestamp-rfc3161 parameter pointing at http://timestamp.acs.microsoft.com. Skipping the timestamp parameter is a mistake that will only show up days later when the signature silently stops validating.

A signed executable’s certificate contains the usual fields you would expect: a Subject matching your validated identity, an Issuer of Microsoft ID Verified CS EOC CA 01, a validity window of about three days, a 3072-bit RSA key, and an Enhanced Key Usage of Code Signing. You can inspect this yourself on any machine:

# Using PowerShell
Get-AuthenticodeSignature "MyApp.exe" | Select-Object -ExpandProperty SignerCertificate | Format-List
 
# Using Windows UI
# Right-click EXE -> Properties -> Digital Signatures tab -> Details -> View Certificate

Where this approach falls short

Azure Trusted Signing is not a fit for every scenario, and it is worth being upfront about the limits rather than presenting it as a universal answer. Regional availability is limited, it is not available in every country, so check availability for your billing region before you invest setup time. The pricing, while cheaper than an annual EV certificate for a solo developer, is a recurring monthly cost that adds up over years compared to a one-time or less frequent certificate purchase, so weigh it against your actual release cadence.

There are also gaps around specific project types. Signing a plain EXE built from a standard publish output works well, but teams building MSIX packages or WAP (Windows Application Packaging) projects through Visual Studio’s packaging UI have reported that the publish dialog only offers Azure Key Vault, Store, or local certificate options, not Trusted Signing directly, so you may need a different signing path for packaging-project scenarios specifically. If your release process depends on frequent, automatic updates to an already-installed application, also verify how your updater handles a signing certificate that changes on every single build, since some auto-update mechanisms expect certificate continuity across versions.

For teams that already own an EV code signing certificate on a hardware token or in an existing HSM-backed vault, the switch to Trusted Signing is not automatically worth the migration effort, particularly if your current signing process is already automated and working reliably. This approach earns its keep mostly for teams starting fresh, solo developers without an existing certificate, or organizations consolidating multiple app signing certificates into one centrally managed Azure resource.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading