Signing & attesting the artifact

cosign signatures, SBOMs, and SLSA provenance in the pipeline.

Intermediate14 min · lesson 11 of 16

When your Jenkins pipeline finishes building a container image and pushes it to a registry, that image is anonymous. Anyone looking at it later has no way to tell whether your pipeline produced it or whether someone swapped it for a look-alike an hour after you pushed. The tag reads checkout:1.4.2, but a tag is a sticky label, and sticky labels peel off and move. This lesson adds three things that turn an anonymous image into one with a history you can check: a signature that ties the image to your pipeline, a list of everything inside it, and a signed record of how it was built. Then you gate the deploy stage so an image that fails those checks never reaches production.

An unsigned image is a package with no return address

A signature is a wax seal on a letter. Break the seal and the reader knows the contents were altered; see an unbroken seal from a sender they trust and they read on. For a container image, cosign (the tool that signs and verifies images, part of the Sigstore project) does the same job. It ties the image's exact contents to the identity that signed them. Those contents are named by a SHA-256 digest (SHA-256 is a hashing algorithm, and the digest it produces is a fingerprint that changes if a single byte changes). If an attacker rebuilds the image with a backdoor and pushes it under the same tag, the digest changes, the old signature no longer matches, verification fails, and your deploy stage refuses to ship it. With no signature there is nothing to check, and the swapped image sails straight through.

This is why you sign the digest and not the tag. A tag like 1.4.2 can be re-pointed at a different image at any time; the digest sha256:9f2a... names one specific set of bytes and nothing else. Sign that, and you have pinned down exactly what you are vouching for. Every step in this lesson works against the same digest, which is what makes the chain hold together.

Keyless signing: an ID badge, not a house key

Keyless signing skips the part everyone dreads, which is minding a private key. Instead of keeping a house key that you have to hide, rotate, and pray never leaks, keyless signing works like showing your ID badge to a guard who writes you a one-hour visitor pass. Your pipeline proves who it is with an OIDC (OpenID Connect) token, a short-lived signed statement of identity that Jenkins can issue for a build. cosign hands that token to Fulcio, the Sigstore certificate authority (CA), which returns a certificate valid for only a few minutes, long enough to sign one image. The signature and that certificate are recorded in Rekor, a public transparency log, so anyone can later confirm that this identity signed this digest at this time. Nothing long-lived is ever stored on disk.

terminal
# $OIDC_TOKEN is a short-lived identity token minted by Jenkins for this build.
# In cosign 2.x keyless is the default: no key flag means "go keyless".
cosign sign --yes \
--identity-token "$OIDC_TOKEN" \
registry.example.com/checkout@sha256:9f2a4c8e...b1
output
Generating ephemeral keys...
Retrieving signed certificate...
Note that there may be personally identifiable information associated with this
signed artifact. This information is stored in a public transparency log and
cannot be removed later.
Successfully verified SCT...
tlog entry created with index: 74650912
Pushing signature to: registry.example.com/checkout
Keyless needs a real OIDC token, and a private key never belongs in the image or the logs
Keyless signing only works if your pipeline can present a valid, unexpired OIDC token. If Jenkins is not set up to mint one, cosign has nothing to send to Fulcio and the sign step fails. That is a configuration problem to fix, not something to route around by pasting a key into a Groovy variable. On the key-based path below, keep the private key out of two places above all others: the image itself (never COPY it into a layer, where anyone who pulls the image can extract it) and the build console (load it through withCredentials, and never echo $COSIGN_KEY or turn on set -x around it). A leaked signing key lets anyone forge your signature, which is worse than not signing, because now the forgery carries a stamp people trust.

The key-based fallback: a key in the Jenkins safe

Keyless needs your pipeline to mint an OIDC token and to reach the public Sigstore services. On a locked-down or air-gapped network where neither is possible, you fall back to a traditional key pair. You generate one with cosign, keep the public half anywhere you like, and treat the private half like the only key to a safe: it goes into the Jenkins credential store and is decrypted only inside the step that needs it. The withCredentials block loads the key file path and its password into environment variables that exist for that one sh step and are masked in the log, so the secret is present where cosign reads it and nowhere else.

Jenkinsfile
stage('Sign (key-based)') {
steps {
withCredentials([
file(credentialsId: 'cosign-key', variable: 'COSIGN_KEY'), // cosign.key file
string(credentialsId: 'cosign-pass', variable: 'COSIGN_PASSWORD') // decrypts the key
]) {
// cosign reads COSIGN_PASSWORD from the environment to unlock the key.
// Neither the key path nor the password is ever echoed.
sh 'cosign sign --yes --key "$COSIGN_KEY" registry.example.com/checkout@sha256:9f2a4c8e...b1'
}
}
}

A bill of materials with syft

An SBOM (Software Bill of Materials) is the ingredients list on the side of a cereal box. It names every package, library, and version baked into your image, so that when a nasty bug lands in some library next month you can answer "is that in anything we shipped?" in seconds instead of guessing. syft is the tool that reads an image and writes that list. Here it emits the list in SPDX (Software Package Data Exchange) format, a widely used standard for SBOMs, into a file you will attach to the image in the next step.

terminal
syft registry.example.com/checkout@sha256:9f2a4c8e...b1 \
-o spdx-json=sbom.spdx.json
output
✔ Pulled image
✔ Loaded image registry.example.com/checkout@sha256:9f2a4c8e...
✔ Parsed image sha256:9f2a4c8e...
✔ Cataloged contents c0e6a2f4b8d1e3f5a7c9...
├── ✔ Packages [153 packages]
├── ✔ Executables [412 executables]
├── ✔ File metadata [2,341 locations]
└── ✔ File digests [2,341 files]

Attaching the SBOM as a signed attestation

A plain sbom.spdx.json file sitting next to the image proves nothing; anyone could hand you a fake one. An attestation staples the list to the image and seals the staple. cosign attest wraps your SBOM in a signed statement that says "this identity vouches that this SBOM describes this exact digest," stores it alongside the image in the registry, and logs it in Rekor. Swap the image and the attestation no longer matches; edit the SBOM and the signature breaks. The signing works the same as before, keyless with your OIDC token or key-based with your key.

terminal
cosign attest --yes \
--identity-token "$OIDC_TOKEN" \
--predicate sbom.spdx.json \
--type spdxjson \
registry.example.com/checkout@sha256:9f2a4c8e...b1
output
Using payload from: sbom.spdx.json
Generating ephemeral keys...
Retrieving signed certificate...
Successfully verified SCT...
tlog entry created with index: 74651003

SLSA provenance: the chain-of-custody form

SLSA (Supply-chain Levels for Software Artifacts, said out loud as "salsa") provenance is the chain-of-custody form that ships with a package: which factory, which recipe, which raw materials, which batch. For a build, that means which pipeline built the image, from which git commit, at which build number. You write the facts into a small predicate file (in a real pipeline your Jenkinsfile fills these in from build variables), then sign it onto the image the same way you signed the SBOM. Now a verifier can confirm two things: that the image is yours, and that it came out of your build system rather than off someone's laptop.

provenance.json
{
"buildDefinition": {
"buildType": "https://jenkins.example.com/pipeline/v1",
"externalParameters": {
"repository": "https://git.example.com/checkout",
"ref": "refs/heads/main"
},
"resolvedDependencies": [
{ "uri": "git+https://git.example.com/checkout@4b8c9d1",
"digest": { "gitCommit": "4b8c9d1e2f..." } }
]
},
"runDetails": {
"builder": { "id": "https://jenkins.example.com/job/checkout-build" },
"metadata": { "invocationId": "build-482" }
}
}
terminal
cosign attest --yes \
--identity-token "$OIDC_TOKEN" \
--predicate provenance.json \
--type slsaprovenance1 \
registry.example.com/checkout@sha256:9f2a4c8e...b1
output
Using payload from: provenance.json
Generating ephemeral keys...
Retrieving signed certificate...
Successfully verified SCT...
tlog entry created with index: 74651078

Verify before you deploy, and gate on it

Signing is worthless if nobody checks the signature, so the deploy stage becomes the bouncer at the door. cosign verify pulls the signature and its certificate and confirms two things you spell out: that the signer's identity matches the pipeline you expect (--certificate-identity) and that the certificate came from the issuer you expect (--certificate-oidc-issuer). Those two flags are required for keyless verification in cosign 2.x precisely so you cannot accidentally accept a signature from any random signer; you are naming who is allowed to sign. If you signed with a key instead, you pass --key cosign.pub here. A mismatch, a missing signature, or a tampered image all make cosign exit with a non-zero code.

terminal
cosign verify \
--certificate-identity "https://jenkins.example.com/job/checkout-build/" \
--certificate-oidc-issuer "https://jenkins.example.com/oidc" \
registry.example.com/checkout@sha256:9f2a4c8e...b1
output
Verification for registry.example.com/checkout@sha256:9f2a4c8e...b1 --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted certificate authority certificates
[{"critical":{"identity":{"docker-reference":"registry.example.com/checkout"},
"image":{"docker-manifest-digest":"sha256:9f2a4c8e...b1"},"type":"cosign container image signature"},
"optional":{"Issuer":"https://jenkins.example.com/oidc",
"Subject":"https://jenkins.example.com/job/checkout-build/"}}]
Jenkinsfile
stage('Verify & deploy') {
steps {
// cosign exits non-zero on any failure; a failed sh step aborts the stage,
// so deploy.sh below is reached only when verification passed.
sh '''
cosign verify \
--certificate-identity "https://jenkins.example.com/job/checkout-build/" \
--certificate-oidc-issuer "https://jenkins.example.com/oidc" \
registry.example.com/checkout@sha256:9f2a4c8e...b1
'''
sh './deploy.sh registry.example.com/checkout@sha256:9f2a4c8e...b1'
}
}

That ordering is the whole point of the gate. Because a failing sh step stops the stage, deploy.sh never runs unless cosign printed its green checks first. An image with no signature, a signature from the wrong identity, or a digest that does not match what was signed cannot slip past into production; the pipeline goes red and stops. Verify by digest here too, the same digest you signed, so the thing you check is the exact thing you ship.

Build once, then prove it: sign, attest, verify, deploy
1Build & push image
Jenkins builds the container and pushes it; the registry returns a digest sha256:9f2a...
2cosign sign (keyless)
OIDC token to Fulcio for a short-lived cert, signature made on the digest and logged in Rekor
3syft + cosign attest
generate an SBOM of everything inside, attach it as a signed attestation on the same digest
4cosign attest (SLSA)
attach a signed provenance statement of which pipeline and which commit produced it
5cosign verify (the gate)
deploy stage checks the signer identity and issuer; any failure exits non-zero and aborts
6Deploy
reached only when verify passed, so an unverified or swapped image never ships
Every step after Build keys off the same SHA-256 digest, so a swapped image breaks the chain and the verify gate stops the deploy before deploy.sh ever runs.
Quick check
01Your pipeline signs the image by its digest with cosign, and the deploy stage runs cosign verify before shipping. After you sign, an attacker pushes a backdoored image to the registry under the same tag, checkout:1.4.2. What happens when the deploy stage runs?
Incorrect — No. cosign signs and verifies the digest, not the tag. The backdoored image is different bytes, so it has a different digest, and the signature made for the original digest does not apply to it.
Correct — Changing any byte changes the SHA-256 digest; the signature was bound to the original digest, so verification finds no matching signature, cosign exits non-zero, and deploy.sh never runs.
Incorrect — No. The verify step runs inside the deploy stage, right before deploy.sh, exactly so the check happens at ship time and can stop a swap that occurred after the build.
Incorrect — No. cosign never signs anything during verify; verify only checks existing signatures against the identity and issuer you named, and reports pass or fail.

Related