.NET code & nupkg signing in GitHub Actions

This guide will walk you through using a combination of GitHub actions and Entra managed identities to enable signing code and NuGet packages from within an action without needing to worry about access tokens.

Introduction

This guide will walk you through signing .NET assemblies, EXEs and NuGet packages using a combination of GitHub actions and Entra managed identities.

Using managed identities allows you to stop worrying about expiring access tokens or leaking the secrets that hold them. Code Signing means no more (well, less) Windows Defender complaints about your executable being suspicious.

The process involves creating and a user-assigned managed identity and assigning it a GitHub action. It uses GitHub environments to isolate the signing stage and its configuration secrets from steps that compile your code. Code signing is accomplished using the dotnet sign tool.

Picking your signing solution

If you're worried about forgetting to renew your Authenticode signing certificate, and you're signing executables then Trusted Signing is probably the solution for you. Trusted Signing issues and renews your certificates and is a lot cheaper than buying an annual certificate.

Publishing nupkgs signed with Trusted Signing to nuget.org is currently an unsupported scenario. If you want to use Trusted Signing with nuget.org please react or add your feedback to the "Support Azure Trusted Signing certificate on NuGet.org account settings" issue.

If you're signing NuGet packages and you want to publish them on nuget.org you will need an Authenticode Signing Certificate stored in an Azure Key vault. If you're concerned about forgetting to renew DigiCert supports integration with Key vault and automatically handle certificate renewal and rotation. You will still need to update nuget.org with the new certificate details once a year.

You can go look at the GitHub actions I use for producing my idunno.Authentication NuGet packages. There are two separate actions, prerelease.yml which uses Trusted Signing and publishes to MyGet and GitHub packages, and release.yml which uses a certificate stored in an Azure Key vault and publishes to nuget.org.

Prerequisites

You should already have

  • A GitHub account and admin rights on the repository you want to configure the actions on.
  • An Azure account and subscription where you have owner rights, and either
    • An Authenticode Signing Certificate stored in an Azure Premium Key vault, or
    • A Trusted Signing account with an active certificate profile.
  • Azure CLI installed and up to date.

Configuration values you will need

Configuration Where to find it
Azure Subscription ID Available once you log in to Azure.
Azure Tenant Id Available once you log in to Azure
Azure Managed
Identity Client ID
Available when you create a Managed Identity.
GitHub Repository Owner The first path segment in the URI for the repository
you want to assign managed identities to.

e.g. for https://github.com/contoso/WebSite/

the owner is contoso
GitHub Repository Name The second path segment in the URI
for the repository you
want to assign managed identities to.

e.g. for https://github.com/contoso/WebSite/

the Repository Name is WebSite
GitHub Actions Environment Available when you assign
a Managed Identity to a GitHub Action.

If you are going to sign with a certificate store in an Azure Key vault you will also need

Configuration Where to find it
Vault URI On the overview page of your Key Vault
Certificate Name In the Certificates list of your Key Vault

If you are going to sign with Trusted signing you will also need

Configuration Where to find it
Account Name Available when granting permission to Trusted signing
Account Uri Available when granting permission to Trusted signing
Certificate Profile Available when granting permission to Trusted signing

Authenticating GitHub Actions to Azure

The first step to configuring signing and release in a GitHub action is to configure the action to authenticate to Azure. This requires a User Assigned Managed Identity which is then federated to GitHub Actions.

Creating a User Assigned Managed Identity

First you need to create a User Assigned Managed Identity.

  1. Open PowerShell and authenticate to Azure, picking the subscription that contains your Trusted Signing account or the Key vault that contains your signing key.
    az login
  2. Set the subscription id and tenant id for later use
    $subscriptionId = az account show --query id --output tsv
    $tenantId = az account show --query tenantId --output tsv
  3. Set the Azure resource group you want the identity to be created in
    $resourceGroup = "resourceGroupName"
  4. Set the name of the Managed Identity you want to create, and then create it
    $managedIdentityName = "managedIdentityName"
    az identity create \

    --name $managedIdentityName \
    --resource-group $resourceGroup
    $clientId = az identity show \
    --name $managedIdentityName \
    --resource-group $resourceGroup \
    --query clientId --output tsv
    $managedIdentityId = az identity show \
    --name $managedIdentityName \
    --resource-group $resourceGroup \
    --query principalId --output tsv

Assigning a User Assigned Managed Identity to a GitHub Action

Now you have a User Assigned Managed Identity it needs to be assigned to one or more GitHub Actions. This is accomplished by adding a Federated Credential to the managed identity that has the properties necessary to use from a GitHub action.

  1. Pick the GitHub repo you want to create your signing action in.
  2. Make a note of the repo owner and name. These are case sensitive.
  3. Pick an Environment name for the GitHub action. This is used to isolate the secrets needed to login and sign to a signing stage in the action we will create, for example sign. You will also need this when configuring the action.
  4. Construct a subject string for the federated credential you are about to create in the following format
    $subject = "repo:repoOwner/repoName:environment:environmentName"

    For example, if we wanted to create a federated credential for the Signing.Example repo owned by Contoso, with an environment name of sign you would use
    $subject = "repo://contoso/Signing.Example:environment:sign"

    Remember everything is case sensitive.
  5. Pick a name for the federated credential.
    $credentialName = "credentialName"
  6. Create a new Federated Credential for the User Assigned Managed Identity you created in the previous step
    az identity federated-credential create \
    --name $credentialName \
    --identity-name $managedIdentityName \
    --resource-group $resourceGroup \
    --issuer https://token.actions.githubusercontent.com \
    --subject $subject

Configuring the GitHub Action Environment

Next you need to create and configure the environment for your GitHub actions.

  1. Log in to GitHub.
  2. Choose the repository you want to add the signing action to by navigating to it.
  3. From the repository view select Settings.
  4. Select Environments from the left-hand menu.
  5. Select New environment, then give it the same name as you configured the federated credential entity for in Assigning a User Assigned Managed Identity to a GitHub Action, step 3, then select Configure Environment.
  6. In Environment Secrets add the following secrets and the appropriate values.
    These were saved as you created the managed identity and can be shown again in your PowerShell session, with the echo command listed under the value description
Secret Name Secret Value
AZURE_CLIENT_ID The Client ID of the managed identity.
echo $clientId
AZURE_SUBSCRIPTION_ID The Subscription ID the managed identity was created in.
echo $subscriptionId
AZURE_TENANT_ID The Tenant ID the subscription belongs to.
echo $tenantId

7. Create any deployment and branch protection rules you require.

Signing in a GitHub action

A basic two step GitHub action with a managed identity

Finally, you’re at the point where you can write a GitHub action which can authenticate with Azure. The goal here is to have a two (or three once you include publishing) stage action, where the first stage is the typical build/test, producing unsigned artifacts, the second signs the output of the build/test, producing signed artifacts, and then optionally a publish stage to, for example, NuGet.

To start, let’s define an action with two stages, build and sign. Signing .NET executables or nupkgs requires using a Windows runner and will also use the environment you created in Configuring the GitHub Action Environment.

What follows is a typical workflow for building NuGet packages. Y can easily modify for executables by swapping out dotnet pack for dotnet publish.

I have omitted the action hashes in the example to make things easier to read, but as you write your own workflow you should pin each action to its version hash to avoid the potential harm that could be caused by an attacker gaining control of an action, injecting malicious code and then retagging the version of the action containing the code to an existing version tag.

name: Build, Test, Sign
on:
  workflow_dispatch

env:
  OUTPUT_DIRECTORY: ${{ github.workspace}}/output

permissions:
  contents: read

jobs:
  build:
    name: Build & Test
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:

    - name: 'Checkout repository'
      uses: actions/checkout
        persist-credentials: false

    - name: 'Setup .NET SDK'
      uses: actions/setup-dotnet
      with:
        dotnet-version: | 
          10.0.x

    - name: 'Build'
      run: dotnet build

    - name: 'Test'
      run: dotnet test --no-build

    - name: 'Pack'
      run: dotnet pack --no-build --output ${OUTPUT_DIRECTORY}

    - name: 'Upload unsigned artifacts'
      id: upload
      uses: actions/upload-artifact
      with:
        name: build-artifacts.zip
        path: ${{ env.OUTPUT_DIRECTORY }}/*
        retention-days: 7

  sign:
    name: Sign
    needs: build
    runs-on: windows-latest
    environment: signing
    permissions:
      attestations: write
      contents: read
      id-token: write

    steps:
    - name: 'Setup .NET SDK'
      uses: actions/setup-dotnet

    - name: 'Install Sign CLI'
      run: dotnet tool install --tool-path ./sign --prerelease sign

    - name: 'Gather unsigned artifacts'
      uses: actions/download-artifact
      with:
        name: build-artifacts.zip
        path : ${{ env.OUTPUT_DIRECTORY }}

    - name: 'Authenticate to Azure'
      uses: azure/login
      with:
        client-id: ${{ secrets.AZURE_CLIENT_ID }}
        tenant-id: ${{ secrets.AZURE_TENANT_ID }}
        subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

    #### Signing happens here

    - name: 'Upload signed artifacts'
      uses: actions/upload-artifact
      id: upload
      with:
        name: signed-artifacts.zip
        path: ${{env.OUTPUT_DIRECTORY}}/*
        retention-days: 7

A GitHub action definition to support code and nupkg signing from the action

Make sure to adjust the environment value in the sign step to match the environment you specified when creating the federated credential.

Note the placeholder Signing happens here. We will replace this with the appropriate commands in the next step, depending on whether you are using Azure Trusted Signing or an Azure Key vault.

You can see in the sign step the use of the azure/login with the values produced during the creation of the managed identity. That's all it takes in the action to authenticate but doing anything more requires granting the managed identity the appropriate roles/permissions in Azure.

Code signing with an Authenticode Certificate in Azure Key vault

To use a Key vault hosted certificate to sign your assemblies, executables, or nupkgs you need to assign the correct permissions to the User Assigned Managed Identity, configure the correct secrets for the Key vault and then add the signing command to your GitHub Action.

  1. List all the Key vaults in your subscription
    az keyvault list

    Save the name of the of the Key vault you want to use and its resource group and the retrieve its id.
    $keyVault = "keyVaultName"
    $keyVaultResourceGroup = "resourceGroup"
    $keyVaultId = az keyvault show \
    --name $keyVault \
    --resource-group $keyVaultResourceGroup \
    --query id --output tsv
    $keyVaultUri =
    az keyvault show \
    --name $keyVault \
    --resource-group $keyVaultResourceGroup \
    --query properties.vaultUri --output tsv
  2. Now we need to assign the appropriate roles to the managed identity to sign with.
    az role assignment create \
    --role "Key Vault Certificate Reader" \
    --assignee-object-id $managedIdentityId \
    --scope $keyVaultId
    az role assignment create \
    --role "Key Vault Crypto User" \
    --assignee-object-id $managedIdentityId \
    --scope $keyVaultId
  3. Finally, we need to choose the certificate in the Key vault we want to use. First we list all the available certificates in the vault, and then we save the name.
    az keyvault certificate list --id $keyVaultUri
    $certificate = "name"
  4. Now switch back to GitHub.
  5. Choose the repository you want to add the signing action to by navigating to it, or by choosing it from the Repositories list.
  6. From the repository view select Settings.
  7. Select Environments from the left-hand menu.
  8. Select the environment you configured the federated credential to apply to.
  9. In Environment Secrets add the following secrets and the appropriate values. These were saved as you added the signing permission and can be shown again in your PowerShell session, with the echo command listed under the value description.
Secret Name Secret Value
AZURE_KEYVAULT_URL The name URL of the Key vault to use.
echo $keyVaultUri
AZURE_KEYVAULT_CERTIFICATE The certificate to sign with.
echo $certificate
  1. Switch to your code editor and remove the following from the action yaml.
    #### Signing happens here
  1. Insert the following yaml where you removed the placeholder comment, between the Authenticate to Azure and Upload steps in the Sign stage
     - name: Sign
       shell: pwsh
       run: >
         ./sign/sign code azure-key-vault *.nupkg 
         --base-directory ${env:OUTPUT_DIRECTORY} 
         --azure-key-vault-url "${{ secrets.AZURE_KEY_VAULT_URL }}" 
         --azure-key-vault-certificate "${{ secrets.AZURE_KEY_VAULT_CERTIFICATE }}"

A GitHub action job step for signing with an Azure Key vault stored certificate.

  1. Run your action and you should now have two sets of artifacts, build-artifacts and signed-artifacts.
At the time of writing, with version 0.9.1-beta.25379.1 of the sign tool if you examine the logs for the sign stage may see in your sign stage logs one or more exceptions from Azure.Identity which complain about authentication being unavailable. The stage however says it was successful. You can ignore the exceptions, signing did work. See below on how to validate a file has been signed.

Code signing with Trusted Signing

To use Trusted Signing to sign your assemblies, executables, or nupkgs, you need to assign the correct permissions to the User Assigned Managed Identity, configure the correct secrets for Trusted Signing and then add the signing command to your GitHub Action.

  1. List all the Trusted Signing accounts in your subscription
    az trustedsigning list

    Save the name of the of the account you want to use. its resource group and retrieve its accountUri.
    $trustedSigningAccount = "accountName"
    $trustedSigningResourceGroup = "resourceGroup"
    $accountUri = az trustedsigning show \
    --name $trustedSigningAccount \
    --resource-group $trustedSigningResourceGroup \
    --query accountUri --output tsv

  2. List the certificate signing profiles for the Trusted Signing account and set the name of certificate profile you want to use
    az trustedsigning certificate-profile list \
    --account-name $trustedSigningAccount \
    --resource-group $trustedSigningResourceGroup
    $certificateProfile = "name"

  3. Create the scope the signing permission will granted to. You have two choices for the scope; apply it to the Trusted Signing account, which will allow any certificate profile to be used, or apply it to an individual certificate profile.

    To grant permissions to the Trusted Signing account use
    $scope = az trustedsigning show \
    --account-name $trustedSigningAccount \
    --resource-group $trustedSigningResourceGroup \
    --query id --output tsv


    To grant permissions to the individual certificate profile use
    $scope = az trustedsigning certificate-profile show \
    --name $certificateProfileName \
    --account-name $trustedSigningAccount \
    --resource-group $trustedSigningResourceGroup \
    --query id --output tsv


    Note that you cannot see Certificate Profile scoped role assignments in the Azure portal.
  4. Finally, the last Azure piece is to assign the "Trusted Certificate Profile Signer" role to the managed identity.
    az role assignment create \
    --role "Trusted Signing Certificate Profile Signer" \
    --assignee-object-id $managedIdentityId \
    --scope $scope
  5. Now switch back to GitHub.
  6. Choose the repository you want to add the signing action to by navigating to it, or by choosing it from the Repositories list.
  7. From the repository view select Settings.
  8. Select Environments from the left-hand menu.
  9. Select the environment you configured the federated credential to apply to.
  10. In Environment Secrets add the following secrets and the appropriate values. These were saved as you added the signing permission and can be shown again in your PowerShell session, with the echo command listed under the value description.
Secret Name Secret Value
AZURE_TRUSTEDSIGNING_ACCOUNT The name of the Trusted Signing account to use.
echo $trustedSigningAccount
AZURE_TRUSTEDSIGNING_ACCOUNTURI The URI of the Trusted Signing Account.
echo $accountUri
AZURE_TRUSTEDSIGNING_CERTIFICATEPROFILE The certificate profile to sign with.
echo $certificateProfile
  1. Switch to your code editor and remove the following from the action yaml.
    #### Signing happens here
  1. Insert the following yaml where you removed the placeholder comment, between the Authenticate to Azure and Upload steps in the Sign stage
    - name: Sign
      shell: pwsh
      run: >
        ./sign/sign code trusted-signing *.nupkg
        --base-directory ${env:OUTPUT_DIRECTORY}
        -tsa "${{ secrets.AZURE_TRUSTEDSIGNING_ACCOUNT }}"
        -tse "${{ secrets.AZURE_TRUSTEDSIGNING_ACCOUNTURI }}"
        -tscp "${{ secrets.AZURE_TRUSTEDSIGNING_CERTIFICATEPROFILE }}"

A GitHub action job step for signing with an Azure Trusted Signing account.

  1. Run your action and you should now have two sets of artifacts, build-artifacts and signed-artifacts.
At the time of writing, with version 0.9.1-beta.25379.1 of the sign tool if you examine the logs for the sign stage may see in your sign stage logs one or more exceptions from Azure.Identity which complain about authentication being unavailable. The stage however says it was successful. You can ignore the exceptions, signing did work. See below on how to validate a file has been signed.

Reusing managed identities across repos

You can add up to 20 federated credentials to a single User Assigned Managed Identity. This allows you to have multiple repos use the same, existing credentials and permissions. However, you may prefer individual managed identities per repository enabling you, in case of breach, to revoke a single repository's credentials without affecting signing actions in other repositories.

Debugging

Validating assembly and executable signatures

To validate an assembly or executable signature you must use Windows and PowerShell. In PowerShell use

Get-AuthenticodeSignature <filePath> | Select-Object -ExpandProperty SignerCertificate | Format-List

You should see output something like this:

Subject      : CN=Barry Dorrans, O=Barry Dorrans, L=Bothell, S=Washington, C=US
Issuer       : CN=DigiCert Trusted G4 Code Signing RSA4096 SHA384 2021 CA1, O="DigiCert, Inc.", C=US
Thumbprint   : A75A98B54008714D7B76C253C80C23C52E02266E
FriendlyName :
NotBefore    : 7/25/2025 17:00:00
NotAfter     : 10/23/2026 16:59:59
Extensions   : {System.Security.Cryptography.Oid, System.Security.Cryptography.Oid, System.Security.Cryptography.Oid,
               System.Security.Cryptography.Oid…}

If the executable or assembly is unsigned nothing will be returned.

Validating nupkg signatures

To validate a NuGet package is signed use

dotnet nuget verify <filePath>

You should see output something like this:

Verifying test
Content hash: M+OIv2sa4SVzuqRGB1V27HgdORcCiFlW3ElJr79SJQPJYx+m8mD2BKby+QEg0iiMvuetczmgXQw12DhYpRfrcg==

Signature type: Author
  Subject Name: CN=Barry Dorrans, O=Barry Dorrans, L=Bothell, S=Washington, C=US
  SHA256 hash: 8043BF33C42BC87254D9F8213E7A97641490AEF26A11E8D30739DDC83C535D12
  Valid from: 11/02/25 07:48:22 to 11/05/25 07:48:22

If the nupkg is unsigned you will see an error,

Verifying test
Content hash: M+OIv2sa4SVzuqRGB1V27HgdORcCiFlW3ElJr79SJQPJYx+m8mD2BKby+QEg0iiMvuetczmgXQw12DhYpRfrcg==

error: NU3004: The package is not signed.

Package signature validation failed.

In conclusion

Now you have all the tools you need to sign your .NET executables, or your nupkgs from your GitHub actions, increasing security as there are no API keys to leak, and making Windows Defender and other anti-malware software happier as your code is signed, and reliability of signing as there are no API keys to access your signing certificates that expire and need rotating.

Subscribe to Ramblings from a .NET Security PM

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe