This post walks through what it takes to code sign Windows installers in the cloud with an Extended Validation (EV) cert.
This process is surprisingly difficult and not well documented. It’s enough of a problem that I cold-emailed the CEO of GitHub about the difficulty of building deployable Windows apps on GitHub infrastructure and ended up speaking with several helpful members of the GitHub Actions team about improving the process and documentation.
Until then, come navigate this disastrous-and-just-barely-functional world of enterprise code signing with me.
Why Bother?
Your installer will throw up a Malware warning on Windows by default.
That’s a bad thing. Installation is the start of the user’s first experience with your product. Adding friction there is unfriendly — especially for less technical users, who might not know what action to take. (Microsoft adding this much friction to the code signing process is also unfriendly, but hey!)
To resolve this friction, you’ll need to cough up and pay the certificate mafia a yearly fee for an “Extended Validation” (EV) code signing certificate.
No, you don’t want the (cheaper) OV cert. That’s a pain in the neck and will still throw up Malware warnings under circumstances you can’t control — especially while getting started as a business. Only the EV cert solves the issue completely.
What’s the damage?
On macOS, you pay a flat rate to Apple to have a dev account — a code signing cert and service is just one of the many benefits. Plus, you can store that cert itself as a secret in CI. Life is easy.
On Windows, it’s nasty:
- You need a EV cert from a third party issuer (not Microsoft). You’ll have the absolute pleasure of selecting which one of the cabal of slow moving, expensive enterprise companies (sporting websites looking like they were made in 1999) you’d like your soul crushed by.
- It’ll cost you to interact with these cert issuers, both in time and money. I paid 709 EUR for a 3 year cert.
- The “old school” method is still dominant: Certs are deployed on a physical dongle, referred to as a Hardware Security Module (HSM). That’s pretty incompatible with modern development workflows that use CI… which is the motivation for this blog post.
- Luckily, the industry is in transition (read: 🐢🐢🤑) and both Google and Azure offer “cloud HSM” services. So…. Microsoft won’t offer you the cert, but they’ll host it. And you’ll need to pay a bit ($5/mo) for opportunity to store your cert on a cloud HSM.
We’re going to use Azure to store the key and GitHub Actions to do the signing. If you just want to fast forward to the end result, check out my Pamplejuce template on GitHub.
Prefer Google as your corporate overlord of choice? Apparently someone made a GitHub Action for Google KMS and Michał has written an extremely extensive article on his journey with Google KMS.
Prefer Amazon? Sorry, they don’t support OV/EV storage at this time.
Step 1: Get the EV cert process started
I went with GlobalSign (no particular endorsement) because they advertise and have explicit support for using Azure as certificate storage.
It cost me 709 EUR for a 3 year cert. I’m hoping to set-it-and-forget-it for 3 years, because despite how much fun I’m having, I don’t want this to be an annual event.
The vetting process takes a while (weeks). It can take longer (months) if you reside in a smaller country and have documents (business license, proof of address, ID) that require notarized translations. My German documents were accepted as-is, and obviously English documents are fine, too.
Regardless, place your order and get started early. They might want things from you like a letter from your accountant, snail mail to confirm your address, etc.
Step 2: Sign up with Azure & create an Azure Key Vault
Azure offers a “Premium” HSM product as a part of Azure Key Vault.
I haven’t used Azure before this. It looks a lot like Amazon AWS — wayyy too many services with wayyyy too much config and wayyyyy too much admin. (People now have full time jobs specializing and getting certified in these platforms!)
After you sign up, go ahead and create a Key Vault.
Again, there are two flavors of Vaults. We’ll need the “Premium” HSM Key Vault which lets you store the fancy HSM certs.
It’s currently $5/mo. They sometimes have promotions going on that offer free service for a year, etc.
A reader was confused by the fact that Azure also offers dedicated HSM. This is for an expensive, single tenant, full-control-over-the-hardware type thing. You don’t need this. You want the Key Vault shared HSM.
Note your key vault name
, the subscription-id
, the resource-group
and the uhhh… the Key Vault’s https URI (🤷♂️ don’t ask). We’ll need that all later.
Step 3: Create a CSR and generate your cert
Once you are vetted and able to generate a certificate by your favorite Third Party Corporate Vulture:
1. Create a Certificate Signing Request (CSR) (on Azure)
2. Use the CSR to generate the actual code signing cert (with your third party)
3. Merge the certs (on Azure)
Here’s GlobalSign’s (of course slightly out of date) instructions.
Important: In the “Advanced Policy Configuration,” make sure “Exportable Private Key” is set to “No”, which will then let you select RSA-HSM as the key type.
RSA-HSM key type is required by GlobalSign as of June 2023. Choosing this means Azure will store the certificate on FIPS 140-2 level 2 certified HSM.

This step is actually pretty quick, but given the amount of waiting that happens before you get your cert, it’s important to get it right!
Note the certificate name you used on Azure — you’ll need it for the signing step later.
Step 4: Setup an Azure “Application”
Install the Azure CLI locally. Assuming macOS, you can brew install azure-cli
.
This will pop open a browser window to authenticate you:
az login
Next, ask your local wizard for the precise incantation and/or decipher this cryptic disaster. You’ll need the subscription-id
(it’s a uuid) and resource-group
(it’s a name) from Azure.
az ad sp create-for-rbac --name "myApp" --role contributor --scopes /subscriptions/{subscription-id}/resourceGroups/{resource-group} --sdk-auth
myApp
is arbitrary. It’s the name of the Azure “Application” you are creating which will do the signing. You could call it “GitHub” or whatever you call your CI workflow.
You’ll get back something magnificent:
{
"clientId": "{uuid}",
"clientSecret": "{uuid}",
"subscriptionId": "{uuid}",
"tenantId": "{uuid}",
}
Make sure to save this entire awkward json result somewhere safe for now. You’ll need to reference it.
Step 5: Add permissions to your Azure client
Use the clientId
uuid you just got and the key vault name
to give your client the appropriate permissions. You can also do this through the UI if you like.
az keyvault set-policy -n {keyVaultName} --key-permissions sign --spn {clientId}
Disregard the garbage output. Toss it out with your indie integrity. Er, you can manually inspect the output json to check to be sure the actual permissions were added.
Oh, but don’t forget: We need to actually get
the cert before we can sign
with it:
keyvault set-policy -n {keyVaultName} --certificate-permissions get --spn {clientId}
You can verify all went well on the website.
Navigate to your Key Vault. Under “Settings” click “Access Policies”. You should see an “Application” with the following policies in place:

Step 6: Package things up
Do you have something to actually sign yet?
InnoSetup is what many people package Windows installers with.
It is on the list of pre-installed software on GitHub Action’s windows-latest
(Windows Server 2022) environment. So on GitHub you don’t need to install anything to create an installer that installs things.
Yes, msix is a thing. If you can figure out how to do that nicely with a few lines of easy to read config, let me know! It looked like an unfriendly birds nest of xml and json to me. 🪹🍝
Step 7: Sign with Azure SignTool
Things get a bit hairy now.
Azure provides docs around accessing keys from GitHub Actions, however they reference a now-deprecated Azure-official GitHub Action which leads to some dead end suggesting to “pass a custom script” to the more general Azure CLI Action.
Corporate dysfunction aside, our goal is to use something something like signtool
(Window’s code signing program) to sign things with the cert.
Enter Azure SignTool.
This (somewhat confusingly) is an unofficial project run by a GitHub employee, but referenced by official Microsoft documentation as The Way to Sign with Azure Key Vault and The Way To Use Key Vault for GitHub Actions Secrets. However those are only pieces of the puzzle and contain some issues, so I recommend following this flow:
Add AzureSignTool
as a step in CI like so:
dotnet tool install --global AzureSignTool
Note that you don’t need to run the az
tool to login, like you might see in other guides.
Ready for more magic incantations?🪄
You’ll run this command to actually sign your installer:
AzureSignTool sign -kvu "${{ secrets.AZURE_KEY_VAULT_URI }}" -kvi "${{ secrets.AZURE_CLIENT_ID }}" -kvt "${{ secrets.AZURE_TENANT_ID }}"-kvs "${{ secrets.AZURE_CLIENT_SECRET }}" -kvc ${{ secrets.AZURE_CERT_NAME }} -tr http://timestamp.digicert.com -v yourInstaller.exe
This means adding the following 5 secrets to your GitHub repo:
AZURE_KEY_VAULT_URI
For unknown reasons, this is an https URI? It is located on your key vault pageAZURE_CLIENT_ID
(uuid from the awkward json result we had above)AZURE_CLIENT_SECRET
(string from the awkward json)AZURE_CERT_NAME
This is just the name of your cert on Azure, you can grab it from the UI. Note that the official Microsoft docs for pipeline signing call this “AzureKeyVaultName” which is terrible — both the key vault and the certificate have names. You’ll want to pass the certificate name to -kvc
. AZURE_TENANT_ID
(uuid from awkward json)

You did it!
This is what the final result might look like on GitHub Actions:
- name: Generate Installer and Sign with EV cert on Azure (Windows)
if: ${{ matrix.name == 'Windows' }}
shell: bash
run: |
iscc "packaging\installer.iss"
mv "packaging/Output/${{ env.PRODUCT_NAME }}.exe" ${{ env.ARTIFACTS_PATH }}/
dotnet tool install --global AzureSignTool
AzureSignTool sign -kvu "${{ secrets.AZURE_KEY_VAULT_URI }}" -kvi "${{ secrets.AZURE_CLIENT_ID }}" -kvt "${{ secrets.AZURE_TENANT_ID }}" -kvs "${{ secrets.AZURE_CLIENT_SECRET }}" -kvc ${{ secrets.AZURE_CERT_NAME }} -tr http://timestamp.digicert.com -v "${{ env.ARTIFACTS_PATH }}/${{ env.PRODUCT_NAME }}.exe"
You can check Pamplejuce, my C++/JUCE audio plugin template repo which runs this action on every build.
Troubleshooting: Azure Permissions
If you get far enough along that you are having permission issues — congratulations! So close!
Luckily the az
error output is actually pretty decent! For example, I was missing the “certificate get permission” and got the error does not have certificates get permission on key vault
.
Troubleshooting: A certificate with (name/id) *** was not found in this key vault.
I ran into this one. Make sure you are passing your certificate name, not your key vault name as the value to -kvc
.
Leave a Reply