In a nutshell
Signing a container image proves it came from you and that nobody altered it in transit. Keyless signing does that without you ever creating, storing, or rotating a private key — the thing that always eventually leaks. And crucially, this lesson is about the other half almost everyone skips: making your Kubernetes cluster actually refuse to run any image that wasn’t signed by the exact identity you trust. A signature nobody checks is just decoration.
Think of a modern office building. The old way of signing is handing every contractor a physical master key — keys get copied, lost, and stolen, and you can never be sure who holds one. Keyless signing is a badge system instead. When the build robot shows up, the front desk (Fulcio) checks its company ID (an OIDC token the CI system mints on the spot), prints a visitor badge that expires in ten minutes (a short-lived certificate), the robot uses it to sign, and the badge is thrown away. Every visit is written in a guest log nobody can erase (Rekor, the transparency log). Then the door to the server room — your Kubernetes admission gate — only opens for a badge issued to the specific robot from the specific company you authorized. Someone holding a perfectly valid badge from the wrong company is still turned away at the door.
That last sentence is the whole game. The security isn’t “was this signed?” — it’s “was this signed by exactly subject X from issuer Y, and can I find that event in the log?” Two strings and a log lookup. Pin them precisely and the gate is real; pin them loosely and you’ve built decorative cryptography.
Level: Expert · Time: ~23 min
Prerequisites: Be comfortable with container images and digests, Kubernetes Pods/Deployments and how admission webhooks intercept them, and CI basics. Keyless signing rides on OIDC — if workload identity feels fuzzy, read GitHub Actions OIDC keyless deploys first. This lesson is the enforcement companion to SLSA supply-chain provenance and SBOMs.
After this you can: sign container images and blobs keylessly from CI with no stored keys; attach SLSA provenance and an SBOM as verifiable attestations; write a ClusterImagePolicy that admits an image only if it was signed by the identity you name; assert facts inside an attestation (not merely its existence); and roll enforcement out safely with warn mode, scoped exemptions, and an audited break-glass path.
Left to right: CI proves its identity with an OIDC token, Fulcio issues a ten-minute certificate, cosign signs the immutable image digest and attaches attestations, Rekor records the event in a public log, and the cluster’s admission gate admits the Pod only when a matching ClusterImagePolicy confirms the signer’s issuer and subject.
A signature you do not verify is just metadata. Plenty of teams wire cosign sign into CI, watch the green check, and call the supply chain “secured” — while the cluster happily pulls and runs whatever digest a Deployment points at. The control that actually matters lives at the admission boundary: a gate that refuses to run an image unless it was signed by the identity you expect, carrying the provenance you require.
This article builds that gate end to end with keyless Sigstore. We’ll sign without managing a single private key, attach SLSA provenance and an SBOM as attestations, and enforce signer identity plus predicate content with the Sigstore policy controller using ClusterImagePolicy. Then we’ll cover the parts vendors gloss over: air-gapped trust roots, break-glass, staged rollout, and auditing the transparency log for signers you never authorized.
1. Keyless signing internals: Fulcio, OIDC, and Rekor
Keyless does not mean unsigned. It means ephemeral keys bound to an identity instead of a long-lived key you have to store, rotate, and eventually leak.
The flow when CI runs cosign sign:
- Cosign generates an ephemeral keypair in memory, valid for seconds.
- It obtains an OIDC identity token from the workload’s environment — GitHub Actions, GitLab, an SPIFFE provider, or a human via browser.
- It sends the token plus the ephemeral public key to Fulcio, the certificate authority. Fulcio validates the token, then issues a short-lived X.509 certificate (~10 minutes) whose SAN encodes the OIDC
subjectand whose extension records theissuer. - Cosign signs the artifact digest with the ephemeral private key.
- The signature, certificate, and a timestamp are recorded in Rekor, the append-only transparency log. Rekor returns an inclusion proof.
- The ephemeral private key is discarded. There is nothing left to steal.
The identity is the trust anchor. A verifier later asks: “was this signed by certificate-identity X, issued by OIDC issuer Y, and is that event in Rekor?” The whole model collapses to two strings — subject and issuer — plus a transparency-log lookup. Get those two strings wrong in your policy and you have decorative cryptography.
The public-good Sigstore instance (
fulcio.sigstore.dev,rekor.sigstore.dev) is rate-limited and best-effort. Treat it as fine for OSS and experimentation, and plan to self-host for anything you’d page someone over. We cover that in section 6.
2. Signing keylessly in CI with cosign and OIDC
The non-negotiable prerequisite is an OIDC token the CI environment can mint without secrets. On GitHub Actions that’s the id-token: write permission; cosign auto-detects the ambient token, so no COSIGN_EXPERIMENTAL flag and no key material are needed.
# .github/workflows/build-sign.yml
name: build-sign
on:
push:
tags: ["v*"]
permissions:
contents: read
packages: write
id-token: write # required: mints the OIDC token Fulcio trusts
jobs:
build:
runs-on: ubuntu-latest
env:
IMAGE: ghcr.io/${{ github.repository }}
steps:
- uses: actions/checkout@v4
- uses: sigstore/cosign-installer@v3 # pins a verified cosign binary
- 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 by digest
id: build
run: |
docker build -t "$IMAGE:${GITHUB_REF_NAME}" .
docker push "$IMAGE:${GITHUB_REF_NAME}"
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE:${GITHUB_REF_NAME}")
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
- name: Keyless sign (by digest, never by tag)
run: cosign sign --yes "${{ steps.build.outputs.digest }}"
Two habits that separate this from a demo:
- Sign the digest, not the tag. Tags are mutable;
cosign sign ghcr.io/app:v1signs whateverv1resolves to now. Always resolve toname@sha256:...first and sign that immutable reference. - Pin the installer and the build.
cosign-installer@v3fetches a known-good binary; if your own toolchain is compromised, your signatures are worthless no matter how good the policy is.
Blobs (Helm charts, Terraform plans, release tarballs, SBOM files) sign the same way, and the artifact moves separately from its signature:
cosign sign-blob --yes \
--bundle artifact.bundle \
release.tar.gz
# verification consumes the bundle, which carries the cert + Rekor entry
3. Attaching SLSA provenance and SBOM attestations
A signature proves who. An attestation proves what — a signed, typed statement (an in-toto predicate) bound to the same digest. Two predicates carry their weight in audits: SLSA provenance (how the artifact was built) and an SBOM (what’s inside it).
The cleanest way to get trustworthy provenance is to not hand-roll it. The SLSA GitHub generator runs in an isolated reusable workflow and emits a provenance attestation at SLSA Build Level 3 — the builder identity is the workflow itself, which is exactly what you’ll pin in policy later.
provenance:
needs: build
permissions:
actions: read
id-token: write
packages: write
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.0.0
with:
image: ghcr.io/${{ github.repository }}
digest: ${{ needs.build.outputs.digest }}
secrets:
registry-username: ${{ github.actor }}
registry-password: ${{ secrets.GITHUB_TOKEN }}
For the SBOM, generate it with Syft and attach it as a keyless attestation under the standard predicate type:
# generate a CycloneDX SBOM for the exact digest
syft "ghcr.io/myorg/app@sha256:abc123..." -o cyclonedx-json > sbom.cdx.json
# attach it as a signed, keyless attestation
cosign attest --yes \
--predicate sbom.cdx.json \
--type cyclonedx \
"ghcr.io/myorg/app@sha256:abc123..."
Each cosign attest produces its own DSSE envelope, signed via Fulcio and logged in Rekor — independently verifiable from the image signature. You now have three claims on one digest: a signature, provenance, and an SBOM. The cluster will demand all three.
4. Deploying the policy controller and writing ClusterImagePolicy
The Sigstore policy controller is a validating admission webhook. It intercepts pod-creating resources, resolves every image to a digest, and checks each against the ClusterImagePolicy objects that match it.
helm repo add sigstore https://sigstore.github.io/helm-charts
helm repo update
helm install policy-controller -n cosign-system --create-namespace \
sigstore/policy-controller
Enforcement is opt-in per namespace — a deliberate design that lets you onboard gradually. The controller only evaluates namespaces carrying its include label:
kubectl label namespace production policy.sigstore.dev/include=true
Now the core policy. This one requires that any ghcr.io/myorg/* image was signed keylessly by a specific GitHub Actions workflow, authenticated by GitHub’s OIDC issuer:
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: require-keyless-signature
spec:
images:
- glob: "ghcr.io/myorg/**"
authorities:
- name: github-actions-signer
keyless:
url: https://fulcio.sigstore.dev
identities:
- issuer: "https://token.actions.githubusercontent.com"
subject: "https://github.com/myorg/app/.github/workflows/build-sign.yml@refs/tags/v1.0.0"
ctlog:
url: https://rekor.sigstore.dev
mode: enforce
Two evaluation rules govern everything and are worth committing to memory:
- Across policies that match an image: results are ANDed. Every matching
ClusterImagePolicymust pass. - Within a single policy:
authoritiesare ORed. Any one satisfied authority validates that policy. Use this for key rotation — list the old and new signer side by side during a cutover.
Pinning subject to an exact tag is brittle across releases. Use subjectRegExp to accept any tag from the trusted workflow while still rejecting every other identity:
identities:
- issuer: "https://token.actions.githubusercontent.com"
subjectRegExp: "^https://github\\.com/myorg/app/\\.github/workflows/build-sign\\.yml@refs/tags/.*$"
5. Verifying signer identity, issuer, and attestation predicates
Requiring a signature is table stakes. The real control is requiring the right provenance content. The policy controller verifies the attestation signature and then runs a CUE or Rego policy against the decoded predicate, so you can assert facts inside the SLSA statement — not merely that one exists.
This policy demands a SLSA provenance attestation whose builder is your trusted SLSA workflow, signed keylessly:
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: require-slsa-provenance
spec:
images:
- glob: "ghcr.io/myorg/**"
authorities:
- name: slsa-attestation
keyless:
url: https://fulcio.sigstore.dev
identities:
- issuer: "https://token.actions.githubusercontent.com"
subjectRegExp: "^https://github\\.com/slsa-framework/slsa-github-generator/.*$"
attestations:
- name: must-be-slsa-built
predicateType: https://slsa.dev/provenance/v1
policy:
type: cue
data: |
predicate: {
buildDefinition: {
buildType: =~"https://github.com/slsa-framework/slsa-github-generator.*"
}
}
mode: enforce
The predicateType selects which attestation to evaluate; the CUE block then constrains its body — here, that the buildType came from the SLSA generator. You can extend the same pattern to require a CycloneDX SBOM attestation (predicateType: https://cyclonedx.org/bom) and assert, for example, that a specific component or license is or is not present. This is where “we have an SBOM” becomes “we enforce what the SBOM says.”
6. Air-gapped and self-hosted Sigstore
The public instances depend on a TUF (The Update Framework) root delivered over the internet — a non-starter for air-gapped clusters or anyone who refuses to make signing availability someone else’s SLA. Self-hosting means running your own Fulcio, Rekor, and CTLog, and distributing your own trust root.
Sign in CI against the internal instances by overriding the URLs:
cosign sign --yes \
--fulcio-url https://fulcio.internal.example.com \
--rekor-url https://rekor.internal.example.com \
"registry.internal.example.com/app@sha256:abc123..."
On the cluster side, the controller learns to trust your CA and log through the TrustRoot CRD. For a connected mirror you supply the initial root.json and a mirror URL; for a truly air-gapped cluster you embed the entire TUF repository inline as a base64, gzipped tarball via repository.mirrorFS, so the controller never makes an outbound call:
apiVersion: policy.sigstore.dev/v1alpha1
kind: TrustRoot
metadata:
name: internal-sigstore
spec:
repository:
root: | # base64-encoded initial root.json
<BASE64_ROOT_JSON>
mirrorFS: | # base64 of the gzipped, tarred TUF repository (air-gap)
<BASE64_REPOSITORY_TGZ>
Reference the TrustRoot from a policy’s keyless authority with trustRootRef, and the controller validates internal signatures with zero internet egress. Self-hosting buys you control and an availability story you own — at the cost of running a CA and a transparency log, which is real operational weight. Size that before committing.
7. Break-glass, exemptions, and staged enforcement
Ship mode: enforce cluster-wide on day one and you will be the reason a Sev1 mitigation can’t deploy. Roll out in stages.
Warn first. mode: warn runs the full evaluation and surfaces failures as admission warnings, but admits the pod anyway. Run here for a release cycle and watch which workloads would have been blocked:
spec:
mode: warn # logs + warns, never blocks — your dry run
Decide the no-match behavior deliberately. By default, an image matched by no policy in an enforced namespace is rejected (fail-closed) — correct for production. During onboarding, relax it via the controller config so unmatched images warn instead:
# policy-controller config: no-match-policy = warn | allow | deny
data:
no-match-policy: "warn"
Exempt what genuinely can’t be signed — third-party sidecars, vendored base images — narrowly, with a static authority that passes without verification. Scope the glob tightly; a broad static-pass policy is a backdoor:
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: allow-known-sidecars
spec:
images:
- glob: "registry.k8s.io/sig-storage/csi-node-driver-registrar*"
authorities:
- name: trusted-third-party
static:
action: pass
Break-glass is the label, not editing policies under pressure. Keep a pre-approved break-glass namespace whose include label is removed, with tight RBAC and an alert that fires the moment anything lands there. Pulling the label on a normal namespace to unblock an incident is the emergency lever — fast, reversible, and auditable — far safer than hand-editing a ClusterImagePolicy at 3 a.m.
8. Auditing the transparency log
Enforcement keeps bad images out today. The transparency log answers a different question: who has ever signed as us? Because every keyless signature lands in Rekor, you can hunt for signing identities you never authorized — a leaked OIDC path, a rogue workflow, a typo’d subject that still validates somewhere.
Query Rekor for every entry tied to an identity and confirm the issuer is exactly what you expect:
# every Rekor entry for a given signer identity
rekor-cli search --email "https://github.com/myorg/app/.github/workflows/build-sign.yml@refs/tags/v1.0.0"
# inspect a specific log index — confirm subject AND issuer
rekor-cli get --log-index 184392011 --format json \
| jq '.body.HashedRekordObj // .body'
For a continuous control, periodically pull entries for your image repos and diff observed (subject, issuer) pairs against an allowlist. Anything off-list is an alert — it means something obtained an OIDC token for your identity and signed with it. That is the earliest possible signal of a build-system compromise, and the transparency log is the only place you’ll see it before the artifact reaches a cluster.
Enterprise scenario
A payments platform team ran the policy controller in enforce across ~40 production namespaces, pinning the signer subject to each release tag. It worked until a Friday incident required an out-of-band hotfix built from a branch, not a tag. The exact-tag subject match rejected the image; admission blocked the rollout; the mitigation stalled while a sleepy on-call tried to edit ClusterImagePolicy objects under pressure — exactly the failure mode you build this to avoid.
The constraint: keep strict identity enforcement for normal releases, but never let the gate itself become the outage.
Two changes fixed it. First, they loosened the production policy from an exact-tag subject to a subjectRegExp that trusts the build-sign workflow on any ref from their org, so legitimate hotfix builds still verify:
identities:
- issuer: "https://token.actions.githubusercontent.com"
subjectRegExp: "^https://github\\.com/payments/.+/\\.github/workflows/build-sign\\.yml@refs/(heads|tags)/.+$"
Second, they stopped treating policy edits as the emergency procedure. They provisioned a locked-down break-glass namespace — no include label, RBAC restricted to two SREs, and a PagerDuty alert plus a Rekor audit job wired to any pod created there. Break-glass became a labelled, audited deploy target reachable in seconds, and the enforcement policies themselves stayed immutable during incidents. Mean time to mitigate for signing-related blocks dropped from “however long it takes to safely hand-edit a webhook policy” to under two minutes.
Verify
Confirm the gate actually rejects and admits the right things before you trust it.
# 1. Verify the signature locally with the exact identity you'll enforce
cosign verify \
--certificate-identity-regexp "^https://github.com/myorg/app/.*$" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"ghcr.io/myorg/app@sha256:abc123..." | jq '.[0].optional'
# 2. Verify the SLSA provenance attestation
cosign verify-attestation \
--type slsaprovenance \
--certificate-identity-regexp "^https://github.com/slsa-framework/.*$" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"ghcr.io/myorg/app@sha256:abc123..."
# 3. Negative test: an unsigned image MUST be denied in an enforced namespace
kubectl run rogue --image=nginx:latest -n production
# expected: admission webhook "policy.sigstore.dev" denied the request:
# no matching signatures / failed policy: require-keyless-signature
# 4. Positive test: your signed image admits cleanly
kubectl run app --image="ghcr.io/myorg/app@sha256:abc123..." -n production
If step 3 admits the pod, the namespace label is missing or no-match-policy is allow — your gate is open. Fix that before anything else.
Going deeper
What Fulcio actually writes into the certificate
When people say “verify by identity,” these are the exact fields being compared. Fulcio encodes the OIDC subject as a SAN (Subject Alternative Name) URI in the certificate, and the OIDC issuer as a non-standard X.509 extension (Sigstore’s OID 1.3.6.1.4.1.57264.1.8, with 1.3.6.1.4.1.57264.1.1 on older certs). cosign verify --certificate-identity matches the SAN; --certificate-oidc-issuer matches the extension. The ClusterImagePolicy subject/subjectRegExp and issuer fields do the same comparison at admission time. Fulcio also submits the certificate to a Certificate Transparency (CT) log and embeds the returned SCT (Signed Certificate Timestamp), so even the issuance of a signing cert is publicly auditable.
Why you must pin BOTH subject and issuer
This is the single most important security nuance, and the easiest to get wrong. Fulcio will issue a valid certificate to anyone with a valid OIDC token — that’s the design of a public CA for public identities. The trust comes entirely from which identity you accept:
- Issuer only (
issuer: github, subject: .*) → any GitHub Actions workflow on the planet — including an attacker’s public repo — can sign an image your cluster will run. - Subject only, no issuer → an attacker who controls a different OIDC provider can mint a token whose
subjectstring matches your regex, get a real Fulcio cert, and sign. - Both pinned, subject anchored → only the exact workflow, authenticated by the exact issuer, validates.
A loose subjectRegExp is the classic footgun: .*build.* matches your workflow and evil-org/build-malware. Anchor it (^https://github\.com/myorg/...$) and pin the issuer. Everything else in this lesson is plumbing; this is the actual security boundary.
Online, offline, and the transparency-log dependency
By default cosign verify calls Rekor to confirm the signature is logged — a runtime dependency on a log being reachable. Two ways to decouple:
- Sigstore bundles.
cosign sign --bundle(and the newer--new-bundle-format) emit a self-contained bundle carrying the certificate, the Rekor SET (Signed Entry Timestamp), and the inclusion proof. Verifiers check the proof offline — no live Rekor call at verify time. - RFC 3161 timestamps.
--timestamp-server-urlrecords a signed timestamp from a Timestamp Authority, so “this signature existed at time T” no longer depends on Rekor’s clock or availability.
Never reach for --insecure-ignore-tlog=true to “fix” a flaky Rekor. It disables the transparency guarantee — the very thing you deployed all this for.
The webhook’s own failure mode
The admission controller is itself a Pod behind a ValidatingWebhookConfiguration. Its failurePolicy is a genuine trade-off: Fail (recommended for security) means if the policy-controller is down, every Pod create in an included namespace is rejected — the gate can become the outage. Ignore fails open and defeats the point. The controller caches verification results and short-circuits repeated digests, but treat its availability like any other critical control-plane component: multiple replicas, a PodDisruptionBudget, and the monitored break-glass path from section 7.
It isn’t the only gate — pick deliberately
The Sigstore policy-controller is the native choice, but the same admission-time verification exists in tools you may already run. DevSecOps policy gates often standardize on Kyverno instead:
| Tool | Approach | Best when |
|---|---|---|
| Sigstore policy-controller | ClusterImagePolicy CRD, native Sigstore, CUE/Rego on predicates |
You want the reference implementation and deep attestation checks |
Kyverno verifyImages |
General policy engine; signing is one rule among many | You already run Kyverno for other policy and want one control plane |
| Connaisseur | Mutating + validating webhook, multi-validator (cosign, Notary v2) | Mixed signing schemes, or you want tag→digest mutation built in |
| Ratify + Gatekeeper/OPA | External data provider feeding OPA constraints | You standardize on OPA/Gatekeeper and want verification as external data |
The same gate in Kyverno — note mutateDigest rewrites the tag to a digest so what’s admitted is exactly what was verified:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-keyless
spec:
validationFailureAction: Enforce
webhookTimeoutSeconds: 30
rules:
- name: check-signature
match:
any:
- resources: { kinds: ["Pod"] }
verifyImages:
- imageReferences: ["ghcr.io/myorg/*"]
mutateDigest: true # rewrite tag → digest in the admitted Pod
attestors:
- entries:
- keyless:
subject: "https://github.com/myorg/app/.github/workflows/build-sign.yml@refs/tags/*"
issuer: "https://token.actions.githubusercontent.com"
rekor:
url: https://rekor.sigstore.dev
Version and API caveats (2026)
ClusterImagePolicyandTrustRootarepolicy.sigstore.dev/v1beta1and.../v1alpha1respectively — theTrustRootalpha surface still shifts, so pin the chart version.- cosign 2.x made keyless the default: no
COSIGN_EXPERIMENTALenv var and no interactive prompt when--yesis set. Tutorials that export that variable are stale. - Predicate type URIs differ by SLSA version:
https://slsa.dev/provenance/v0.2vshttps://slsa.dev/provenance/v1, and cosign’s--type slsaprovenance(v0.2) vsslsaprovenance1(v1.0). Name the wrong one in a policy and the attestation authority never matches — a properly attested image gets denied (a self-inflicted false negative). Version-match your predicate types to what CI actually writes. - The same Fulcio/Rekor infrastructure now backs npm and PyPI provenance, so this identity-verification mental model transfers directly to language-package supply chains.
Common beginner mistakes
- “Keyless means unsigned, or less secure.” The opposite. There is no long-lived private key to store, rotate, or leak — the ephemeral key lives for seconds and is thrown away. Trust moves from a secret you must protect to an identity you verify, recorded in a public log.
- “We wired up
cosign sign, so our supply chain is secured.” Signing with nothing verifying it changes nothing about what runs; the cluster pulls whatever digest a Deployment references. Security exists only once an admission gate refuses unsigned or wrong-identity images. Right model: signing is the pen, the admission policy is the bouncer. - “Requiring a signature is enough.” Any valid OIDC token yields a valid Fulcio signature. If your policy checks “signed” but not by whom, an attacker’s public workflow passes. Always pin both
issuerand an anchoredsubject/subjectRegExp. - “Sign the tag.”
cosign sign app:v1signs whateverv1points at this second, and the tag can be re-pushed to a different image after signing. Always resolve toname@sha256:…and sign the immutable digest. - “The attestation exists, so we’re covered.” Existence is not content.
cosign attestproving an SBOM is attached says nothing about what’s in it. Assert predicate facts with CUE/Rego, or you’re enforcing the envelope and ignoring the letter. - “The controller is installed, so enforcement is on.” Enforcement is opt-in per namespace via the
policy.sigstore.dev/include=truelabel, and unmatched images followno-match-policy. Miss the label (or leaveno-match-policy: allow) and your gate is wide open while looking installed. - “Break-glass means editing the policy fast during an incident.” Hand-editing a
ClusterImagePolicyat 3 a.m. is how you turn one incident into two. Break-glass is a pre-approved, RBAC-restricted, alerted namespace whose include label is simply absent — flip to it in seconds, audit every Pod that lands there. - “The public Sigstore instance is production infrastructure.”
fulcio.sigstore.dev/rekor.sigstore.devare best-effort and rate-limited. Fine for OSS and learning; for anything you’d get paged over, self-host or add a timestamp authority so signing and verifying aren’t gated on someone else’s SLA.
Practice challenges
Work top to bottom — each builds on the last. Commands use placeholder identities; substitute your own registry, org, and workflow path.
1. Verify a public keyless signature (beginner)
Verify a cosign-signed public image by identity, not just “is it signed.” Which two flags carry the actual security?
<details><summary>Solution</summary>
cosign verify \
--certificate-identity-regexp "^https://github.com/myorg/app/.*$" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"ghcr.io/myorg/app@sha256:abc123..." | jq '.[0].optional'
--certificate-identity[-regexp] (the subject) and --certificate-oidc-issuer are the security. Drop them and you’ve only proved someone signed — not who. Why: identity is the trust anchor; a signature without a pinned identity is decorative.
</details>
2. Arm a namespace and watch it deny (beginner)
With the policy-controller installed, make production enforce and prove an unsigned image is rejected.
<details><summary>Solution</summary>
kubectl label namespace production policy.sigstore.dev/include=true
kubectl run rogue --image=nginx:latest -n production
# Error from server (BadRequest): admission webhook "policy.sigstore.dev"
# denied the request: ... no matching signatures (representative)
Why: enforcement is opt-in per namespace — the include label is what arms the gate. No label, no protection. </details>
3. Pin issuer + subject in a ClusterImagePolicy (intermediate)
Write a policy that admits ghcr.io/myorg/** images signed by any tag of your build-sign.yml workflow, authenticated by GitHub’s issuer.
<details><summary>Solution</summary>
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: require-myorg-signer
spec:
images:
- glob: "ghcr.io/myorg/**"
authorities:
- name: gha-signer
keyless:
url: https://fulcio.sigstore.dev
identities:
- issuer: "https://token.actions.githubusercontent.com"
subjectRegExp: "^https://github\\.com/myorg/[^/]+/\\.github/workflows/build-sign\\.yml@refs/tags/.+$"
ctlog:
url: https://rekor.sigstore.dev
mode: enforce
Why: anchoring the regex (^...$) and pinning the issuer is the difference between “our workflow” and “any workflow on GitHub.”
</details>
4. Add keyless signing to CI (intermediate)
Add a job step that signs the freshly pushed image keylessly, by digest, with no stored keys. Which one permission makes it work?
<details><summary>Solution</summary>
permissions:
id-token: write # mints the OIDC token Fulcio trusts
packages: write
# ...after build & push, with steps.build.outputs.digest = name@sha256:...
- uses: sigstore/cosign-installer@v3
- run: cosign sign --yes "${{ steps.build.outputs.digest }}"
id-token: write lets the job mint the ambient OIDC token; cosign auto-detects it. Why: the OIDC permission is the credential — there is no key to add to secrets.
</details>
5. Enforce what the SBOM says, not that it exists (advanced)
Require a CycloneDX SBOM attestation from the trusted signer, and use CUE to assert a fact about its content.
<details><summary>Solution</summary>
attestations:
- name: require-cyclonedx-sbom
predicateType: https://cyclonedx.org/bom
policy:
type: cue
data: |
predicate: {
bomFormat: "CycloneDX"
specVersion: =~"^1\\."
}
Attach it in CI with cosign attest --predicate sbom.cdx.json --type cyclonedx <digest>. Extend the same CUE to walk predicate.components and reject a banned license or require a specific component. Why: cosign attest binds an SBOM to the digest; the CUE block is what turns “we have an SBOM” into “we enforce what’s in it.”
</details>
6. Design a safe rollout with break-glass (advanced)
You’re switching 40 production namespaces to enforce. Outline the sequence so the gate can never become the outage.
<details><summary>Solution</summary>
- Warn first: ship policies as
mode: warnfor a release cycle; collect which workloads would have been denied. - Decide no-match: set
no-match-policy: warnduring onboarding, thendeny(fail-closed) for prod. - Loosen brittle pins: use
subjectRegExpfor any ref of the trusted workflow, not an exact tag, so hotfix builds still verify. - Scope exemptions:
static: { action: pass }only for specific third-party globs — never a broad pass. - Break-glass namespace: pre-create one with the include label absent, RBAC limited to two SREs, and an alert + Rekor audit on any Pod created there.
- Flip to
enforcenamespace by namespace, watching admission denials.
Why: every step keeps identity enforcement strict while making the failure lever fast, reversible, and audited — so an emergency is a label flip, not a 3 a.m. policy edit. </details>
Glossary
- Sigstore — an open-source project (Fulcio + Rekor + cosign, plus supporting infra) for signing and verifying software by identity instead of managed keys.
- cosign — the CLI that signs, attests, and verifies container images and blobs. In v2, keyless is the default.
- Keyless signing — signing with a short-lived key bound to an OIDC identity, so there is no long-lived private key to store, rotate, or leak.
- OIDC token — a short-lived, cryptographically signed identity token a CI system (or human) presents to prove who it is. GitHub Actions mints one when a job has
id-token: write. - Fulcio — Sigstore’s certificate authority. It validates an OIDC token and issues a ~10-minute X.509 certificate binding that identity to an ephemeral public key.
- Rekor — Sigstore’s append-only transparency log. Every signature is recorded there and returns an inclusion proof; you can later audit who signed what.
- Ephemeral key — a keypair generated in memory, used for one signing operation over a few seconds, then discarded.
- Digest — the immutable
sha256:…content hash of an image. Unlike a tag, it can’t be re-pointed. Always sign and admit by digest. - Attestation — a signed, typed statement about an artifact (an in-toto predicate) bound to its digest — e.g., how it was built or what’s inside it.
- in-toto predicate — the typed body of an attestation;
predicateTypenames its schema (SLSA provenance, CycloneDX SBOM, …). - DSSE envelope — Dead Simple Signing Envelope, the wrapper cosign uses to sign an attestation’s payload.
- SLSA provenance — a standardized attestation describing how and where an artifact was built (builder identity, source, parameters). Build Level 3 comes from an isolated, trusted builder.
- SBOM — Software Bill of Materials: the inventory of components and dependencies inside an artifact (here, CycloneDX).
- policy-controller — Sigstore’s validating admission webhook for Kubernetes; it verifies images against
ClusterImagePolicyobjects at Pod-create time. - ClusterImagePolicy — the CRD that says which images (
glob) must be signed by which identities (authorities), and in whatmode(warn/enforce). - Authority — one acceptance rule inside a policy.
keylessverifies by Fulcio identity;static: { action: pass }exempts an image without verification. - subject / issuer — the two strings that are the identity:
subjectis who signed (the workflow or email),issueris which OIDC provider vouched for them. Pin both. - TrustRoot — the CRD that teaches the controller to trust a self-hosted or air-gapped Fulcio/Rekor via your own TUF root.
- no-match-policy — controller setting for images matched by no policy in an enforced namespace:
deny(fail-closed),warn, orallow. - Break-glass — a pre-approved, RBAC-restricted, alerted namespace with enforcement off, used to deploy in an emergency without editing policy.