Certified Kubernetes Security Specialist (CKS) #15 Image signing: cosign, SBOM
In #14 Image scan: Trivy, Kubesec, KubeLinter, we used scanning to look inside an image and see what vulnerabilities it carried. But scanning asks “is this image safe,” and before that there’s a more fundamental question: “is this image really the one we built?” A tag alone can’t tell you whether the myapp:1.0 you pulled from a registry is what your CI built, or a different image an attacker pushed under the same tag. That’s because a tag can be overwritten at any time.
What fills this trust gap is image signing. The build owner leaves a signature on the image, and verifying that signature at deploy time guarantees both origin and integrity. This post covers two pillars: handling signing and verification with sigstore’s cosign, and documenting what an image is made of with an SBOM (Software Bill of Materials).
Why sign: the starting point of trust #
The moment a container image is pushed to a registry, it becomes a public asset anyone can pull. The problem is that a tag is an identifier, not evidence of trust. The name registry.example.com/myapp:1.0 is just a pointer to one manifest inside the registry, and if anyone with write access overwrites that same tag with a different image, the name stays the same while only the contents change. This is the classic entry point for a supply chain attack.
An image does have one identifier that never changes: the digest. This hash, which starts with sha256:, is computed over the entire image content, so if even one byte differs, the digest differs. So for integrity, pinning an image by digest instead of by tag is the first step.
# Pin the image by digest instead of by tag
spec:
containers:
- name: app
image: registry.example.com/myapp@sha256:3a1b...e9f0But a digest only guarantees integrity — it doesn’t prove origin. You can know “this image hasn’t changed since it was built,” but “a trustworthy party built this image” is a separate question. To prove origin too, you need the build owner’s signature. This is where cosign comes in.
cosign: sigstore’s signing tool #
sigstore is an open source project for easily bringing signing and verification to the software supply chain, and cosign is the command-line tool within it for signing and verifying container images. cosign’s key idea is that it stores signatures alongside the image in the registry where it lives, with no separate infrastructure. A signature is pushed as a separate artifact linked to the original image digest.
cosign supports two broad signing methods.
| Method | Key management | Characteristics |
|---|---|---|
| Key-based (key pair) | Hold the private and public keys yourself | Simple. You’re responsible for key leakage and rotation |
| keyless (OIDC) | No key storage | Short-lived certificates issued against an OIDC identity. Suited to CI |
For the exam, key-based signing and verification is the fundamental you need at your fingertips, while keyless is enough to understand conceptually.
Generating a key pair #
First, create the key pair you’ll use for signing. When you enter a password, a private key file protected by that password and a public key file are generated.
# cosign.key (private key) and cosign.pub (public key) are generated
cosign generate-key-paircosign.keyis the private key used for signing. Never expose it externally.cosign.pubis the public key used for verification. It’s safe to distribute to verifiers.
In production, rather than keeping the private key as a file, you’d store it in a KMS (AWS KMS, GCP KMS, etc.) or a Kubernetes Secret. cosign also supports putting the key straight into a Secret with the form cosign generate-key-pair k8s://<namespace>/<secret>.
Signing an image #
Sign the image with the private key you generated. It’s safest to specify the target image by digest rather than by tag where possible. If you sign by tag, cosign resolves the digest at that moment and signs it, but giving the digest explicitly makes your intent clear.
# Sign the image with the private key
cosign sign --key cosign.key registry.example.com/myapp:1.0Once signing finishes, the registry stores a signature artifact named sha256-....sig alongside the original image. Because the signature lives in the registry itself, you don’t need to run a separate signature store.
Verifying a signature #
On the deploy side, you verify the signature with the public key. A successful verification confirms that the image was signed by the holder of that private key and hasn’t been changed since.
# Verify the image signature with the public key
cosign verify --key cosign.pub registry.example.com/myapp:1.0On success, cosign prints the signature payload to standard output. On failure — that is, if the signature is missing, signed with a different key, or the image has been tampered with — it errors out with a nonzero exit code. This exit code becomes the gate for the automation we’ll cover later.
# Decide pass/fail by the exit code
cosign verify --key cosign.pub registry.example.com/myapp:1.0 \
&& echo "Signature verification passed" \
|| echo "Signature verification failed: block deploy"keyless signing (OIDC) in one shot #
The key-based approach carries the burden of securely storing the private key and rotating it periodically. keyless signing removes that burden. At signing time, you authenticate with an OIDC identity (e.g., a GitHub Actions workflow identity, or your Google/GitHub account), sigstore’s certificate authority (Fulcio) issues a short-lived certificate bound to that identity, and the signing record is left in a transparency log (Rekor). Since no private key is stored, it’s especially well suited to CI pipelines.
# Sign with an OIDC identity (authenticate via browser or CI token)
cosign sign registry.example.com/myapp:1.0# keyless verification specifies which identity and issuer to trust
cosign verify \
--certificate-identity "https://github.com/myorg/myrepo/.github/workflows/build.yml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
registry.example.com/myapp:1.0In keyless verification, instead of a public key you specify who (certificate-identity) signed from where (certificate-oidc-issuer) as the trust condition. This lets you set a policy like “only let through images signed by our main branch’s build workflow” without any key management.
SBOM: the parts list that makes up an image #
If a signature proves “who built this image,” an SBOM (Software Bill of Materials) documents “what this image is made of.” An SBOM is the complete list of components that went into the image — OS packages, language libraries, versions, licenses, and so on. It’s manufacturing’s bill of materials (BOM) carried straight over to software.
The reason an SBOM matters is that it ties directly into vulnerability tracking. When a new critical vulnerability is disclosed (e.g., the Log4Shell of the past), the first question you must answer is “which of our images use that library.” If the SBOM is already made, you can answer this question instantly. Without an SBOM, you have to rescan every image to find out.
SBOM formats: SPDX and CycloneDX #
An SBOM isn’t a free-form document for humans to read — it follows a standard format. For one tool to generate it and another to consume it, the format has to be standardized. There are two leading standards.
| Format | Steward | Characteristics |
|---|---|---|
| SPDX | Linux Foundation | Started license-compliance-centric. An ISO standard |
| CycloneDX | OWASP | Security and vulnerability-tracking-centric. Lightweight |
Both are widely used, and most tools output both formats. On the exam, the key is to specify precisely which format to generate.
Generating an SBOM with syft #
syft is a tool that extracts components from an image or file system to build an SBOM. You pick the output format with an option.
# Generate an SBOM from the image (default output)
syft registry.example.com/myapp:1.0# Save to a file in SPDX (JSON) format
syft registry.example.com/myapp:1.0 -o spdx-json=sbom.spdx.json# Save to a file in CycloneDX (JSON) format
syft registry.example.com/myapp:1.0 -o cyclonedx-json=sbom.cdx.jsonA generated SBOM isn’t an end in itself — it becomes the input to the next step. The vulnerability scanner grype can take an SBOM as input and match vulnerabilities against it, instead of scanning the image directly.
# Match vulnerabilities with the SBOM as input
grype sbom:sbom.spdx.jsonTrivy, covered in #14, also supports both SBOM generation and SBOM-based scanning. The flow is the same. Build the component list once, and check it against each new vulnerability as it appears.
Combining SBOM and signing: attach and attestation #
An SBOM is only meaningful if it travels with the image. cosign supports either attaching an SBOM to an image or signing it as an attestation that proves provenance and binding it on. An attestation is a way to vouch, with a signature, that “this SBOM for this image is genuine.”
# Turn the SBOM into an attestation and sign/attach it to the image
cosign attest --key cosign.key \
--predicate sbom.spdx.json \
--type spdxjson \
registry.example.com/myapp:1.0# Verify the attached SBOM attestation
cosign verify-attestation --key cosign.pub \
--type spdxjson \
registry.example.com/myapp:1.0This way, both the image’s origin (signature) and composition (SBOM) come to live together in the registry in a verifiable form. This state is exactly the goal of supply chain security.
Enforcing signatures with admission #
The commands so far have been a flow where a human verifies by hand. But what you actually need in a real cluster is to keep unsigned images from being deployed in the first place. For that, the cluster has to verify the signature at the gateway where it admits Pods — that is, at admission control.
The flow goes like this. When kubectl requests Pod creation, the API server hands that request to an admission webhook, the webhook verifies the image signature the cosign way, and then decides to pass or reject. A Pod using an image that’s unsigned or signed with an untrusted key is blocked at the creation stage.
kubectl apply
│
▼
API server ──→ admission webhook (signature verification)
│ pass: allow Pod creation
└ fail: reject Pod creationThe tools that express this verification as policy are exactly the topic of the next post: OPA/Gatekeeper and Kyverno. Kyverno in particular has a verifyImages rule, letting you declaratively write a policy like “images from this registry must be signed with this public key.” sigstore’s own policy-controller is a dedicated admission controller that fills the same role.
# Kyverno: the gist of a policy requiring a cosign signature on images from a given registry
spec:
rules:
- name: verify-signature
match:
any:
- resources:
kinds: [Pod]
verifyImages:
- imageReferences:
- "registry.example.com/*"
attestors:
- entries:
- keys:
publicKeys: |
-----BEGIN PUBLIC KEY-----
...contents of cosign.pub...
-----END PUBLIC KEY-----The detailed policy syntax and a comparison with OPA/Gatekeeper are covered in detail in #16 Admission control: OPA/Gatekeeper, Kyverno. For this post, just keep in mind the connection: we’ve made the materials — signatures and SBOMs — and admission enforces those materials.
Exam points #
For the Supply Chain Security domain of the CKS exam, the key with image signing and SBOM is to get the following at your fingertips.
- Key pair generation. Make
cosign.keyandcosign.pubwithcosign generate-key-pair. Don’t mix up the roles of the two. - Signing.
cosign sign --key cosign.key <image>. Pin the target by digest where possible. - Verification.
cosign verify --key cosign.pub <image>. Know that a failed verification yields a nonzero exit code. - keyless concept. Without a private key, you get a short-lived certificate from an OIDC identity to sign and verify, and in verification you specify the trust condition with
--certificate-identityand--certificate-oidc-issuer. - SBOM generation. Specify the format explicitly with
syft <image> -o spdx-jsonor-o cyclonedx-json. You should be able to explain the difference between SPDX and CycloneDX in one line. - The purpose of an SBOM. It’s a tracking means: build the component list in advance to instantly find affected images when a new vulnerability appears.
- Digest pinning. Since tags can be tampered with, specify the image by
@sha256:digest where integrity matters.
On the exam, tasks like “sign this image with the given key,” “verify the signature with this public key and leave the result in a file,” and “generate this image’s SBOM in SPDX format” are regulars. If you can type a single command with the exact options, it turns into points fast.
Wrap-up #
What this post locked in:
- A tag is not evidence of trust. Pin integrity with the digest, and prove origin with a signature.
- cosign. sigstore’s signing tool. Make a key with
generate-key-pair, sign withsign, verify withverify. The signature is stored alongside the image in the registry where it lives. - keyless (OIDC). Sign with an identity-based short-lived certificate without storing a private key. Suited to CI pipelines.
- SBOM. The component list of an image. Generate it in SPDX/CycloneDX format with syft; it becomes the basis for instantly tracking the blast radius when a new vulnerability appears.
- Admission enforcement. Beyond hand verification, block deployment of unsigned images at the cluster’s entrance with Kyverno, OPA/Gatekeeper, and policy-controller.
Signatures and SBOMs are the materials of supply chain security. Making the cluster enforce these materials automatically is the final step.
Next: Admission control #
The question we deferred at the end of this post — “so how do we make the cluster automatically reject unsigned images” — is what the next post tackles head-on.
In #16 Admission control: OPA/Gatekeeper, Kyverno, we’ll work through how an admission webhook operates, OPA/Gatekeeper’s Rego policy and its ConstraintTemplate/Constraint structure, Kyverno’s declarative policy and the verifyImages rule, and exam-regular policies like “require a specific registry/signature” and “reject privileged Pods” by writing them ourselves.