This is Part 6 of the Cloud Security Engineering crash course. In previous parts we covered IAM hardening, secrets management, and supply chain security. Now we tackle the mechanism that ties everything together: code signing.
If supply chain security is about knowing what goes into your software, code signing is about proving who put it there and that nothing changed along the way. It is the cryptographic guarantee that the code running in production is exactly what a trusted developer wrote and a trusted pipeline built.
Why Code Signing Matters
Every deployment is a trust decision. When your Kubernetes cluster pulls an image, it is trusting that the image is what it claims to be. When a developer installs an npm package, they trust the publisher. When a reviewer merges a PR, they trust the commit author.
Without code signing, all of that trust is implicit. An attacker who compromises a registry, a CI runner, or a developer laptop can inject malicious code and nobody would know until it is too late.
Code signing solves three problems:
- Authenticity — it proves the identity of the signer.
- Integrity — it proves the artifact has not been modified since signing.
- Non-repudiation — the signer cannot deny they signed it.
Real-world incidents make the case clearly. The SolarWinds attack succeeded because a tampered build artifact passed through the pipeline without any signature verification. The Codecov bash uploader was modified in transit. The ua-parser-js npm hijack pushed malicious code under a trusted package name. In every case, signature verification at the point of consumption would have caught the tampering.
Signing Git Commits (GPG + SSH)
The first link in the chain of trust is the commit itself. Git supports two signing methods: GPG and SSH.
GPG Signing
Generate a GPG key and configure Git to use it:
# Generate a new GPG key (use RSA 4096 or ed25519)
gpg --full-generate-key
# List your keys to find the key ID
gpg --list-secret-keys --keyid-format=long
# Configure Git to use this key
git config --global user.signingkey YOUR_KEY_ID
git config --global commit.gpgsign true
# Export your public key for GitHub/GitLab
gpg --armor --export YOUR_KEY_IDNow every commit you make will be signed automatically. Upload the public key to GitHub under Settings > SSH and GPG keys to get the green “Verified” badge.
SSH Signing (Git 2.34+)
If you already have SSH keys, you can skip GPG entirely. SSH signing is simpler to set up:
# Tell Git to use SSH for signing
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
# Create an allowed-signers file for verification
echo "[email protected] $(cat ~/.ssh/id_ed25519.pub)" >> ~/.config/git/allowed_signers
git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signersI prefer SSH signing for teams because everyone already has SSH keys and key distribution is already handled by GitHub/GitLab. One less tool in the stack.
Verifying Commits
# Verify a single commit
git verify-commit HEAD
# Show signature info in log
git log --show-signature -1
# Enforce signed commits in CI
git verify-commit HEAD || exit 1Signing Docker Images with Cosign
Git commit signing covers source code. But what about the built artifact? A signed commit means nothing if the Docker image was tampered with after the build. This is where cosign from the Sigstore project comes in.
Install and Sign
# Install cosign
brew install cosign # or go install github.com/sigstore/cosign/v2/cmd/cosign@latest
# Generate a key pair (for key-based signing)
cosign generate-key-pair
# Sign an image after pushing to a registry
cosign sign --key cosign.key ghcr.io/myorg/myapp:v1.2.3
# Verify the signature
cosign verify --key cosign.pub ghcr.io/myorg/myapp:v1.2.3Cosign stores signatures as OCI artifacts alongside the image in the registry. No separate signature store needed.
Attaching Metadata
You can attach additional claims to a signature, which is powerful for policy enforcement:
# Sign with custom annotations
cosign sign --key cosign.key \
-a "repo=github.com/myorg/myapp" \
-a "workflow=release" \
-a "commit=$(git rev-parse HEAD)" \
ghcr.io/myorg/myapp:v1.2.3Sigstore and Keyless Signing
Managing long-lived signing keys is a security problem in itself. If the private key is compromised, every past signature becomes suspect. Sigstore’s keyless signing eliminates this risk entirely.
Here is how keyless signing works:
- The developer (or CI system) authenticates via OIDC — GitHub Actions, Google, Microsoft, etc.
- Fulcio, the Sigstore certificate authority, issues a short-lived signing certificate bound to the OIDC identity. The certificate is valid for about 10 minutes.
- The artifact is signed with the ephemeral private key.
- The signature and certificate are recorded in Rekor, a tamper-evident transparency log.
- The private key is discarded. It never needed to be stored anywhere.
Verification works by checking the Rekor log entry and the Fulcio certificate chain. No keys to manage, no keys to rotate, no keys to leak.
# Keyless signing in CI (uses ambient OIDC credentials)
cosign sign ghcr.io/myorg/myapp:v1.2.3
# Keyless verification — checks Fulcio + Rekor automatically
cosign verify \
--certificate-identity "https://github.com/myorg/myapp/.github/workflows/release.yml@refs/tags/v1.2.3" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/myorg/myapp:v1.2.3In GitHub Actions, keyless signing happens automatically when you run cosign sign without specifying a key. The workflow’s OIDC token is used as the identity. This is the approach I recommend for most teams — zero key management overhead.
Verifying Signatures in CI
Signing is only half the equation. You must verify at every trust boundary. Here is a GitHub Actions workflow that builds, signs, and then verifies before deployment:
name: Build, Sign, and Deploy
on:
push:
tags: ["v*"]
permissions:
contents: read
packages: write
id-token: write # Required for keyless signing
jobs:
build-sign:
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Verify the Git commit is signed
- name: Verify commit signature
run: git verify-commit HEAD
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/myorg/myapp:${{ github.ref_name }}
# Keyless sign — uses GitHub OIDC automatically
- name: Sign image
run: cosign sign ghcr.io/myorg/myapp@${{ steps.build.outputs.digest }}
deploy:
needs: build-sign
runs-on: ubuntu-latest
steps:
- name: Install cosign
uses: sigstore/cosign-installer@v3
# Verify BEFORE deploying
- name: Verify image signature
run: |
cosign verify \
--certificate-identity "https://github.com/myorg/myapp/.github/workflows/release.yml@refs/tags/${{ github.ref_name }}" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/myorg/myapp@${{ needs.build-sign.outputs.digest }}
- name: Deploy to production
run: echo "Deploying verified image..."The key detail: the deploy job verifies the signature before deploying. If someone tampered with the image in the registry between the build and deploy stages, the verification fails and the deployment is blocked.
npm Package Signing
The JavaScript ecosystem has its own signing story. npm introduced provenance in 2023, built on Sigstore. When you publish from a supported CI environment, npm automatically generates a provenance attestation linking the published package to its source repo and build.
# Publish with provenance from GitHub Actions
npm publish --provenance
# Check provenance on the npm website or CLI
npm audit signaturesIn your package.json, you can add a publishConfig block:
{
"publishConfig": {
"provenance": true
}
}When a consumer runs npm audit signatures, npm verifies that every installed package with provenance was built from the claimed source repository. This catches scenarios where a maintainer’s token is stolen and used to publish from a different machine.
For private registries that do not support Sigstore, you can sign packages with GPG manually:
# Sign the tarball
npm pack
gpg --detach-sign --armor mypackage-1.0.0.tgz
# Consumers verify before installing
gpg --verify mypackage-1.0.0.tgz.asc mypackage-1.0.0.tgzBuilding a Chain of Trust
Individual signing steps are useful, but the real power comes from connecting them into an unbroken chain from developer to production.
Each link in the chain verifies the previous one:
- Developer signs commits with GPG or SSH keys. The identity is tied to a verified email on GitHub/GitLab.
- CI verifies commit signatures before running the build. Unsigned or unverified commits are rejected.
- CI signs the build artifact using keyless signing (cosign + Sigstore). The signature is bound to the CI workflow identity, not a human.
- The registry stores the signature alongside the artifact as an OCI artifact or attestation.
- The deployment system verifies the signature before pulling the image. Kubernetes admission controllers like Kyverno or Connaisseur can enforce this automatically.
- Production runs only verified images. Any break in the chain halts deployment.
Enforcing in Kubernetes with Kyverno
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signatures
spec:
validationFailureAction: Enforce
rules:
- name: check-cosign-signature
match:
any:
- resources:
kinds:
- Pod
verifyImages:
- imageReferences:
- "ghcr.io/myorg/*"
attestors:
- entries:
- keyless:
subject: "https://github.com/myorg/*"
issuer: "https://token.actions.githubusercontent.com"This policy ensures that no image from your organization’s registry can run in the cluster unless it has a valid Sigstore signature from your GitHub Actions workflows. An attacker who pushes a malicious image directly to the registry is blocked.
Key Takeaways
- Sign at every stage — commits, build artifacts, and packages. Each signature closes a different attack surface.
- Prefer keyless signing with Sigstore/cosign for CI pipelines. No keys to manage means no keys to compromise.
- Verify at every trust boundary — before build, before deploy, before pod admission. Signing without verification is security theater.
- Use SSH signing for Git commits if your team already uses SSH keys. It is simpler than GPG and has first-class GitHub support.
- Enable npm provenance for public packages. It is a one-flag change that protects your downstream consumers.
- Enforce with admission controllers in Kubernetes. Kyverno and Connaisseur can reject unsigned images at the cluster level.
- Automate everything. If signing or verification requires a manual step, it will be skipped. Bake it into the pipeline.
Code signing is not glamorous work. It does not add features or improve performance. But it is the cryptographic backbone that makes every other security control trustworthy. Without it, your SBOM is unverified, your vulnerability scans are unattested, and your deployment pipeline is an honor system.
Start with Git commit signing this week. Add cosign to your container builds next week. Enforce verification in your cluster the week after. Three weeks, three layers of trust, and a supply chain that is dramatically harder to compromise.











