You’ve built your Windows application, it’s ready to ship, and then reality hits: users see scary security warnings when they try to run your software. Or worse, SmartScreen blocks it entirely. Sound familiar?
Code signing solves this problem, but here’s the catch – storing your private signing key on a developer’s laptop or build server is asking for trouble. One compromised machine and your reputation goes down the drain. That’s where AWS KMS comes in.
I’ve been signing binaries in production for years, and moving to AWS KMS was one of those decisions that seemed complicated at first but made perfect sense once I got it working. Let me walk you through exactly how to do this.
Why Your Binaries Need Signing (And Why It Matters)
Here’s what happens when you don’t sign your Windows executables:
Your users download your perfectly legitimate software, try to run it, and Windows throws up a “Windows protected your PC” screen. They have to click “More info” and then “Run anyway” – that’s two extra clicks where half your users will bail out thinking you’re distributing malware.
Even if they do run it, SmartScreen starts tracking your application. If enough people click through those warnings, you might end up on a blocklist. I’ve seen this happen to legitimate software companies, and it’s a nightmare to recover from.
Code signing fixes this. When you sign your binary with a proper certificate, Windows knows the software came from you and hasn’t been tampered with. The warnings disappear, your software installs smoothly, and you look professional.
The Old Way vs. The AWS KMS Way
Traditional code signing works like this: you get a certificate from a Certificate Authority (CA), they give you a .pfx file containing your certificate and private key, and you store it… somewhere. Maybe on your build server, maybe in a “secure” network share, maybe on someone’s laptop.
The problem? That private key is everything. Anyone who gets it can sign software as if they’re you. I’ve personally seen companies have their code signing certificates stolen and used to sign actual malware. It’s not theoretical – it happens.
AWS KMS changes the game. Your private key never leaves AWS’s hardware security modules. You can’t export it, steal it, or accidentally commit it to GitHub. When you need to sign something, you send the hash to AWS, they sign it with your key, and send back the signature. The key itself stays locked away.
What You Actually Need
Before we start, here’s what you need to have ready:
A code signing certificate from a real CA – DigiCert, Sectigo, GlobalSign, etc. You can’t just make one yourself; Windows won’t trust it. These run about $100-$400 per year depending on the type.
The certificate’s private key – This usually comes as a .pfx or .p12 file when you first get your certificate. Keep this safe during the import process, then delete it from your local machine once it’s in KMS.
An AWS account – You’ll need permissions to create KMS keys and write IAM policies. If you’re in a corporate environment, you might need to ask your cloud team for help.
Windows SDK – This gives you SignTool.exe, which is Microsoft’s official signing tool. It’s free and comes with Visual Studio, or you can download just the SDK.
Basic command line comfort – We’ll be running commands in PowerShell and Bash. Nothing too crazy, but you should know how to navigate directories.
Understanding AWS KMS (Without the Marketing Speak)
AWS KMS is basically a vault for encryption keys. The keys live in FIPS 140-2 validated hardware security modules – fancy tamper-resistant boxes that AWS manages. You can create keys, use them for encryption or signing, and set policies for who can use them.
For code signing, we’re using KMS’s asymmetric key feature. You create an RSA key pair (public and private), the private key stays in KMS forever, and you use the public key in your certificate.
Cost-wise, it’s pretty reasonable: $1/month per key, plus $0.15 per signing operation for RSA_2048 keys. If you’re signing 1000 builds a month, that’s about $151. Way cheaper than dealing with a compromised certificate.
One thing to know: KMS has some limitations. You can’t use it directly with every signing tool, and the signing process is a bit more involved than just pointing SignTool at a .pfx file. But the security tradeoff is worth it.
Setting Up Your KMS Key
Let’s get our hands dirty. First, we need to create a KMS key and import your existing private key into it.
Creating the KMS Key
Log into the AWS Console and head to KMS. Click “Create key” and choose these settings:
- Key type: Asymmetric
- Key usage: Sign and verify
- Key spec: RSA_2048 (most certificates use this)
Give it a name like “windows-code-signing-key” and add a description so you remember what it’s for six months from now.
Here’s the important part – the key policy. This controls who can use the key to sign things. Start with this basic policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Enable IAM User Permissions",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::YOUR-ACCOUNT-ID:root"
},
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "Allow signing operations",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::YOUR-ACCOUNT-ID:user/YOUR-CI-USER"
},
"Action": [
"kms:Sign",
"kms:GetPublicKey",
"kms:DescribeKey"
],
"Resource": "*"
}
]
}
Replace YOUR-ACCOUNT-ID and YOUR-CI-USER with your actual values. This gives your CI/CD user permission to sign, but not to delete or modify the key.
Importing Your Private Key
Here’s where it gets tricky. You can’t just upload a .pfx file to KMS. You need to extract the private key, convert it to the right format, and then import it.
First, extract your private key from the .pfx file:
openssl pkcs12 -in your-certificate.pfx -nocerts -out private-key.pem -nodes
It’ll ask for the .pfx password. Once you’ve got the private key, you can import it to KMS using the AWS CLI:
# Get the wrapping key from KMS
aws kms get-parameters-for-import \
--key-id YOUR-KEY-ID \
--wrapping-algorithm RSAES_OAEP_SHA_256 \
--wrapping-key-spec RSA_2048 \
--output text \
--query 'PublicKey' | base64 --decode > wrapping-key.bin
# Wrap your private key
openssl pkeyutl -encrypt \
-in private-key.pem \
-out wrapped-key.bin \
-inkey wrapping-key.bin \
-keyform DER \
-pubin \
-pkeyopt rsa_padding_mode:oaep \
-pkeyopt rsa_oaep_md:sha256
# Import it to KMS
aws kms import-key-material \
--key-id YOUR-KEY-ID \
--encrypted-key-material fileb://wrapped-key.bin \
--import-token fileb://import-token.bin
After this, delete private-key.pem, wrapped-key.bin, and any other copies of your private key. Seriously, delete them. Overwrite them if you’re paranoid. The whole point is that the key only exists in KMS now.
Actually Signing a Binary
Now for the main event. Signing with KMS isn’t as simple as running SignTool with a file path, because the private key isn’t in a file anymore. We need to do this in steps.
The Manual Way (So You Understand What’s Happening)
Here’s the process broken down:
Step 1: Calculate the hash of your binary
signtool sign /fd SHA256 /dg . /v YourApp.exe
This creates a file called YourApp.exe.dig containing the hash that needs to be signed.
Step 2: Send that hash to AWS KMS to get it signed
# Convert the .dig file to base64
$digest = Get-Content -Path "YourApp.exe.dig" -Encoding Byte
$digestBase64 = [Convert]::ToBase64String($digest)
# Sign it with KMS
aws kms sign \
--key-id YOUR-KEY-ID \
--message $digestBase64 \
--message-type DIGEST \
--signing-algorithm RSASSA_PKCS1_V1_5_SHA_256 \
--output text \
--query 'Signature' | base64 --decode > signature.bin
Step 3: Attach the signature to your binary
signtool sign /fd SHA256 /di signature.bin /v YourApp.exe
This works, but it’s a pain to do manually every time. Let’s automate it.
The Automated Way (What You’ll Actually Use)
I wrote a PowerShell script that handles this whole process. Here’s a simplified version:
param(
[Parameter(Mandatory=$true)]
[string]$BinaryPath,
[Parameter(Mandatory=$true)]
[string]$KmsKeyId,
[Parameter(Mandatory=$true)]
[string]$CertificatePath,
[string]$TimestampServer = "http://timestamp.digicert.com"
)
$ErrorActionPreference = "Stop"
# Step 1: Generate digest
Write-Host "Generating digest for $BinaryPath..."
$digestPath = "$BinaryPath.dig"
& signtool sign /fd SHA256 /dg . /v $BinaryPath
if ($LASTEXITCODE -ne 0) { throw "Failed to generate digest" }
# Step 2: Sign with KMS
Write-Host "Signing with AWS KMS..."
$digestBytes = [System.IO.File]::ReadAllBytes($digestPath)
$digestBase64 = [Convert]::ToBase64String($digestBytes)
$signatureBase64 = aws kms sign `
--key-id $KmsKeyId `
--message $digestBase64 `
--message-type DIGEST `
--signing-algorithm RSASSA_PKCS1_V1_5_SHA_256 `
--query 'Signature' `
--output text
$signatureBytes = [Convert]::FromBase64String($signatureBase64)
$signaturePath = "$BinaryPath.sig"
[System.IO.File]::WriteAllBytes($signaturePath, $signatureBytes)
# Step 3: Apply signature to binary
Write-Host "Applying signature to binary..."
& signtool sign /fd SHA256 /di $signaturePath /ac $CertificatePath /t $TimestampServer /v $BinaryPath
if ($LASTEXITCODE -ne 0) { throw "Failed to apply signature" }
# Cleanup
Remove-Item $digestPath, $signaturePath
Write-Host "Successfully signed $BinaryPath"
Save this as Sign-WithKMS.ps1 and use it like:
.\Sign-WithKMS.ps1 `
-BinaryPath "dist\MyApp.exe" `
-KmsKeyId "arn:aws:kms:us-east-1:123456789012:key/abc-123" `
-CertificatePath "cert-chain.pem"
The Certificate Chain Problem
One gotcha that tripped me up: you need to provide the full certificate chain when signing, not just your certificate.
Your code signing certificate is signed by an intermediate CA certificate, which is signed by a root CA certificate. Windows needs to see this whole chain to trust your signature.
Get the certificate chain from your CA’s website. They usually provide a “CA Bundle” or “Intermediate Certificates” file. Combine them into one file:
cat your-cert.pem intermediate.pem root.pem > cert-chain.pem
Use this cert-chain.pem file in your signing script.
Timestamping: Don’t Skip This
Here’s something that caught me off guard when I first started: code signatures expire when your certificate expires. If you signed something in 2024 with a certificate that expires in 2025, users can’t verify the signature after 2025.
Unless you timestamp it.
Timestamping adds a trusted timestamp from a third-party server to your signature, proving “this was signed when the certificate was valid.” The signature remains valid even after the certificate expires.
Always use the /t or /tr flag with SignTool:
signtool sign /tr http://timestamp.digicert.com /td SHA256 ...
The timestamp server is free to use. If one is down, try another:
- http://timestamp.digicert.com
- http://timestamp.sectigo.com
- http://timestamp.globalsign.com
I’ve had timestamp servers go down during builds, so my scripts try multiple servers with fallback logic.
Verification: Make Sure It Actually Worked
After signing, verify the signature before you ship anything:
signtool verify /pa /v YourApp.exe
This checks:
- The signature is valid
- The certificate chain is trusted
- The timestamp is present and valid
You should see output like:
Successfully verified: YourApp.exe
Number of files successfully Verified: 1
If you see errors about the certificate not being trusted, you might be missing intermediate certificates in your chain.
You can also right-click the .exe in Windows Explorer, go to Properties > Digital Signatures, and verify the signature shows your company name and is timestamped.
Putting It in Your CI/CD Pipeline
This is where the real value shows up. Every build, automatically signed, no manual intervention.
GitHub Actions Example
Here’s a workflow that builds and signs a Windows application:
name: Build and Sign
on:
push:
branches: [ main ]
jobs:
build-and-sign:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: Build application
run: |
dotnet build -c Release
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Install AWS CLI
run: |
choco install awscli -y
- name: Sign executable
run: |
.\scripts\Sign-WithKMS.ps1 `
-BinaryPath "bin\Release\MyApp.exe" `
-KmsKeyId "${{ secrets.KMS_KEY_ID }}" `
-CertificatePath "certs\cert-chain.pem"
- name: Verify signature
run: |
signtool verify /pa /v "bin\Release\MyApp.exe"
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: signed-binary
path: bin\Release\MyApp.exe
Store your KMS key ID and AWS credentials in GitHub Secrets. Never commit them to your repository.
Jenkins Pipeline Example
For Jenkins, here’s a Groovy pipeline:
pipeline {
agent { label 'windows' }
environment {
AWS_DEFAULT_REGION = 'us-east-1'
KMS_KEY_ID = credentials('kms-key-id')
}
stages {
stage('Build') {
steps {
bat 'dotnet build -c Release'
}
}
stage('Sign') {
steps {
withCredentials([
[$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: 'aws-credentials']
]) {
powershell '''
.\\scripts\\Sign-WithKMS.ps1 `
-BinaryPath "bin\\Release\\MyApp.exe" `
-KmsKeyId $env:KMS_KEY_ID `
-CertificatePath "certs\\cert-chain.pem"
'''
}
}
}
stage('Verify') {
steps {
bat 'signtool verify /pa /v "bin\\Release\\MyApp.exe"'
}
}
}
}
Things That Will Go Wrong (And How to Fix Them)
“The requested operation is not supported”
This usually means you’re trying to use SignTool directly with KMS, which doesn’t work. You need to use the digest/sign/apply workflow I showed earlier.
“Cannot find the certificate”
Check your certificate chain file. Make sure it includes all intermediate certificates in the right order (leaf → intermediate → root). Also verify the file path is correct.
“The specified timestamp server either could not be reached”
Timestamp servers go down sometimes. Add retry logic to your signing script, or maintain a list of backup timestamp servers to try.
“Access denied” from AWS KMS
Check your IAM permissions. The user or role needs kms:Sign, kms:GetPublicKey, and kms:DescribeKey permissions on your KMS key.
Signature is valid but SmartScreen still complains
This is normal for new certificates. SmartScreen builds reputation over time. As more users run your signed software without issues, the warnings decrease. It can take weeks or months. EV certificates get better treatment initially, but they’re more expensive and harder to get.
File is too large to sign
KMS has a limit on the size of data it can sign directly (about 4KB). The digest approach we’re using gets around this, but if you’re trying to sign the entire file directly, you’ll hit this error.
Security Best Practices That Actually Matter
Limit who can sign – Only your CI/CD pipeline should have access to the KMS key. Not developers, not system administrators, just the automated build process. Use IAM roles, not hardcoded credentials.
Enable CloudTrail logging – Every time something gets signed, it should be logged. This creates an audit trail. If someone compromises your build system and starts signing malicious code, you’ll at least know about it.
Rotate certificates before they expire – Set a calendar reminder for 90 days before your certificate expires. Getting a new certificate and importing it to KMS takes time, and you don’t want to be doing this at 11 PM the day before it expires.
Test your disaster recovery – What happens if AWS has an outage in your region? Can you fail over to another region? Do you have backups of your certificate chain? Test this before you need it.
Monitor signing operations – Set up CloudWatch alerts if the number of signing operations spikes unexpectedly. That could indicate someone is abusing your build system.
Don’t share KMS keys across environments – Use separate keys for dev, staging, and production. If your dev environment gets compromised, your production signing key is still safe.
The Cost Reality Check
Let’s do some actual math. Say you have:
- 1 KMS key: $1/month
- 500 builds per month (signing once per build): 500 × $0.15 = $75/month
- CloudTrail logging: ~$2/month for the S3 storage
Total: About $78/month, or $936/year.
Compare that to:
- Dealing with a compromised certificate: Days or weeks of work, reputation damage, potential re-signing of all previously distributed software, and a new certificate ($100-400)
- The peace of mind knowing your signing key can’t be stolen
For most companies, that’s a no-brainer trade-off.
If cost is a concern, you can reduce it by:
- Batching signatures (sign multiple files in one build process)
- Using KMS only for production releases, not every development build
- Archiving old CloudTrail logs to cheaper storage
When KMS Might Not Be Right for You
KMS isn’t perfect for everyone. Here’s when you might want to look elsewhere:
You’re signing hundreds of files per minute – The per-signature cost adds up fast, and API rate limits might slow you down. In this case, CloudHSM might be better.
You need EV certificate support – As of now, you can’t import EV certificate private keys to KMS. They’re typically locked to hardware tokens that you can’t export from. You’d need to use the hardware token directly or look at dedicated code signing solutions.
You’re in a regulated industry with specific HSM requirements – Some compliance frameworks require FIPS 140-2 Level 3 or higher. KMS is Level 2 in most cases. CloudHSM is Level 3.
You have a massive signing operation – If you’re signing thousands of files daily across multiple platforms, you might want to look at dedicated code signing platforms like DigiCert ONE or Azure Code Signing.
What About Azure Key Vault or Google Cloud KMS?
They work similarly to AWS KMS, with minor differences:
Azure Key Vault has direct SignTool integration through the Azure SignTool extension, which makes it a bit easier to use on Windows. If you’re already an Azure shop, it might be the path of least resistance.
Google Cloud KMS works fine but has fewer Windows-specific tools. The signing process is similar to AWS KMS.
The choice usually comes down to where your other infrastructure lives. If you’re on AWS for everything else, use AWS KMS. Don’t add another cloud provider just for code signing.
Wrapping Up
Code signing with AWS KMS takes a bit of setup, but once it’s working, it’s incredibly reliable. Your private key is locked away in tamper-resistant hardware, every signature is logged, and your CI/CD pipeline handles everything automatically.
The first time you set this up will take a few hours. You’ll hit some roadblocks, probably make some mistakes with the certificate chain, and spend time getting the IAM permissions right. That’s normal. Once it’s done, though, it just works.
Start with one application, get the process solid, then roll it out to your other projects. Document what you did (future you will thank present you). And test your verification process – I can’t stress that enough.
If you get stuck, the AWS support forums are surprisingly helpful, and the KMS documentation is actually pretty good once you get past the marketing speak.
Now go sign some binaries.
Quick Reference Commands
Extract private key from PFX:
openssl pkcs12 -in cert.pfx -nocerts -out private.pem -nodes
Create certificate chain:
cat leaf.pem intermediate.pem root.pem > chain.pem
Sign with KMS (using script):
.\Sign-WithKMS.ps1 -BinaryPath app.exe -KmsKeyId arn:... -CertificatePath chain.pem
Verify signature:
signtool verify /pa /v app.exe
Check certificate details:
signtool verify /pa /v /d app.exe
Further Reading
- AWS KMS Documentation: https://docs.aws.amazon.com/kms/
- Microsoft SignTool Docs: https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool
- Code Signing Best Practices (Microsoft): https://learn.microsoft.com/en-us/windows-hardware/drivers/install/code-signing-best-practices
- NIST FIPS 140-2 Standard: https://csrc.nist.gov/publications/detail/fips/140/2/final





