SolarWinds (2020): attackers compromised the build system and injected malware into a signed software update. 18,000 organizations installed it — because it was signed by SolarWinds’ legitimate code signing certificate.
xz-utils (2024): a social engineering campaign over 2 years inserted a backdoor into a critical Linux compression library. It passed code review, was merged, and would have been distributed to virtually every Linux system.
Codecov (2021): attackers modified a bash script in Codecov’s CI pipeline. Every company using Codecov’s CI integration leaked their environment variables (including secrets) for 2 months.
The common thread: supply chain attacks bypass all endpoint security because the malicious code arrives through trusted channels — signed, reviewed, and distributed by legitimate infrastructure.
Code signing is the first line of defense. But signing alone isn’t enough. You need signing + provenance + verification + monitoring across the entire supply chain.
What Code Signing Actually Protects Against
Code signing provides two guarantees:
1. Authenticity: This software was published by the claimed entity (verified by the CA that issued the signing certificate).
2. Integrity: This software hasn’t been modified since it was signed (any change invalidates the signature).
What code signing does NOT protect against:
- Malicious code signed by a legitimate key (SolarWinds scenario — the build was compromised before signing)
- Compromised signing keys (attacker signs their own malware)
- Dependencies with vulnerabilities (signing doesn’t audit code quality)
This is why code signing is necessary but not sufficient. You also need: build provenance, dependency verification, and runtime verification.
The Modern Code Signing Stack
Layer 1: Sign Everything
Every artifact that leaves your build pipeline should be signed:
| Artifact Type | Signing Method | Tool |
|---|---|---|
| Windows executables (.exe, .dll) | Authenticode | signtool, AzureSignTool |
| macOS applications (.app) | Apple codesign | codesign + notarytool |
| Java archives (.jar, .war) | JAR signing | jarsigner |
| Linux packages (.rpm, .deb) | GPG signing | rpm —addsign, dpkg-sig |
| Container images | OCI signatures | cosign (Sigstore) |
| Helm charts | Provenance | helm package —sign |
| npm packages | Package signatures | npm publish (with provenance) |
| Python packages | PGP/Sigstore | twine upload (with attestation) |
| Go binaries | Sigstore | go build + cosign |
| Firmware | Secure Boot signing | sbsign, UEFI signing tools |
Layer 2: Protect the Signing Key
The signing key is the most valuable asset in your supply chain. If compromised, an attacker can sign anything as you.
Key storage hierarchy:
Best: HSM (FIPS 140-2 Level 3) — key never extractable
Good: Cloud KMS (AWS KMS, Azure Key Vault, GCP KMS) — key never exposed via API
OK: Secrets manager (Vault) — key delivered to signing process at runtime
Bad: CI/CD secret (environment variable) — extractable by any pipeline job
Worst: Developer's laptop — one theft away from compromise
Since June 2023, the CA/Browser Forum requires all code signing private keys to be stored in hardware (HSM, hardware token, or cloud HSM). Software-stored code signing keys are no longer permitted for publicly-trusted certificates.
Layer 3: Build Provenance (SLSA)
Signing proves WHO signed. Provenance proves HOW it was built:
{
"subject": {
"name": "myapp-v2.1.0.tar.gz",
"digest": {"sha256": "abc123..."}
},
"predicate": {
"buildType": "https://github.com/slsa-framework/slsa-github-generator",
"builder": {"id": "https://github.com/actions/runner"},
"invocation": {
"configSource": {
"uri": "git+https://github.com/myorg/myapp@refs/tags/v2.1.0",
"digest": {"sha1": "def456..."}
}
}
}
}
This provenance attestation (SLSA Level 3) proves:
- The artifact was built from this specific Git commit
- On this specific CI/CD platform
- Using this specific workflow file
- No human could have modified the build process
Layer 4: Verify Before Deploy
Signing is useless if nobody checks the signature. Verification must be enforced:
Kubernetes (Kyverno policy):
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-signed-images
spec:
validationFailureAction: Enforce
rules:
- name: verify-cosign-signature
match:
resources:
kinds: ["Pod"]
verifyImages:
- imageReferences: ["ghcr.io/myorg/*"]
attestors:
- entries:
- keyless:
issuer: "https://token.actions.githubusercontent.com"
subject: "https://github.com/myorg/*"
Windows (AppLocker/WDAC):
# Only allow executables signed by specific publishers
New-CIPolicy -Level Publisher -FilePath policy.xml
# Blocks unsigned or unknown-signed executables from running
macOS (Gatekeeper):
# Verify signature before running
codesign --verify --deep --strict MyApp.app
spctl --assess --type execute MyApp.app
CI/CD Pipeline Integration
GitHub Actions (Keyless Signing with Sigstore)
name: Build and Sign
on:
push:
tags: ['v*']
jobs:
build-sign:
runs-on: ubuntu-latest
permissions:
id-token: write # For OIDC token (keyless signing)
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Build container image
run: docker build -t ghcr.io/myorg/myapp:${{ github.ref_name }} .
- name: Push to registry
run: docker push ghcr.io/myorg/myapp:${{ github.ref_name }}
- name: Sign with cosign (keyless)
uses: sigstore/cosign-installer@v3
- run: |
cosign sign ghcr.io/myorg/myapp@$(docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/myorg/myapp:${{ github.ref_name }} | cut -d@ -f2)
- name: Generate SLSA provenance
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v1.9.0
Azure DevOps (HSM-backed Signing)
steps:
- task: DotNetCoreCLI@2
inputs:
command: 'publish'
publishWebProjects: true
- script: |
AzureSignTool sign \
-kvu "$(SigningVaultURL)" \
-kvi "$(SigningClientId)" \
-kvs "$(SigningClientSecret)" \
-kvc "$(CertificateName)" \
-tr http://timestamp.digicert.com \
-td sha256 \
"$(Build.ArtifactStagingDirectory)/**/*.exe"
displayName: 'Sign executables with Azure Key Vault'
GitLab CI (Vault-backed Signing)
sign:
stage: sign
script:
- export VAULT_TOKEN=$(vault write -field=token auth/jwt/login role=signer jwt=$CI_JOB_JWT)
- vault write -field=signature transit/sign/code-signing-key \
input=$(base64 < build/myapp) > signature.b64
- cosign attach signature --signature $(cat signature.b64) \
registry.example.com/myapp:$CI_COMMIT_TAG
Defending Against Supply Chain Attacks
Attack: Compromised Build System (SolarWinds Pattern)
How it works: Attacker gains access to CI/CD, modifies the build to inject malware, legitimate signing key signs the compromised build.
Defenses:
- SLSA Level 3+ provenance: Proves the build came from a specific source commit on a hardened builder. If the build was tampered with, provenance won’t match.
- Reproducible builds: Anyone can rebuild from source and verify the output matches the signed artifact. Injected code would produce a different hash.
- Separated signing: Build system produces unsigned artifacts. A separate, hardened signing system signs them after verification. Compromising the build system doesn’t grant signing access.
Attack: Dependency Confusion
How it works: Attacker publishes a malicious package to a public registry with the same name as your internal package. Build system fetches the public (malicious) version.
Defenses:
- Lock files with hashes:
package-lock.json,go.sum,Pipfile.lockpin exact versions with integrity hashes. Any change is detected. - Private registry priority: Configure package managers to check your private registry first.
- Scoped packages: Use
@myorg/package-name(npm) or organization-scoped names that can’t be squatted on public registries.
Attack: Compromised Signing Key
How it works: Attacker steals the code signing private key and signs malware.
Defenses:
- HSM storage: Key is non-extractable. Attacker must compromise the signing infrastructure, not just steal a file.
- Signing approval gates: Require human approval (or automated policy check) before each signing operation.
- Signing volume monitoring: Alert on unusual signing patterns (signing at 3 AM, signing 100 artifacts when normal is 5/day).
- Certificate Transparency for code signing: Sigstore’s Rekor log records all signing events publicly. Unauthorized signatures are detectable.
Timestamp: The Often-Forgotten Requirement
Code signing without a timestamp has a critical flaw: when the signing certificate expires, the signature becomes unverifiable. Software signed 2 years ago with a now-expired certificate shows “signature expired” warnings.
Timestamping proves WHEN the signature was created. If the certificate was valid at signing time (proven by the timestamp), the signature remains valid forever — even after the certificate expires.
# Always include a timestamp
signtool sign /fd SHA256 \
/tr http://timestamp.digicert.com \ # RFC 3161 timestamp
/td SHA256 \
MyApp.exe
# Without /tr: signature expires with certificate (1-3 years)
# With /tr: signature valid indefinitely
Never skip timestamping. There’s no reason to — timestamp services are free and add milliseconds to the signing process.
FAQ
Q: Do I need code signing if I only deploy to my own infrastructure? A: Yes — code signing proves that what’s running in production is what your CI/CD built. Without it, a compromised deployment pipeline or container registry can inject modified artifacts that look legitimate.
Q: Sigstore (keyless) vs traditional code signing — which should I use? A: Sigstore for container images and internal artifacts (simpler, no key management). Traditional code signing (with HSM-stored key) for software distributed to customers (Windows executables, macOS apps, Java JARs) — because OS verification requires traditional certificates.
Q: How do I handle signing for open-source projects? A: Use Sigstore (free, keyless, identity-based). Sign with your GitHub identity. Consumers verify the signature came from the expected GitHub Actions workflow. No signing keys to manage or protect.
Q: What happens if my signing key is compromised? A: Revoke the certificate immediately (contact your CA). All software signed after the compromise date is suspect. Software signed before (with valid timestamps) remains trustworthy. Notify customers. Generate a new key. Re-sign current releases with the new key.