In short

A valid signature only proves who signed; your policy decides whether that signer may release this artifact. Verify the immutable digest with cosign verify or cosign verify-blob, passing the exact --certificate-identity (release workflow and ref) and --certificate-oidc-issuer, and pin the trigger and repository claims as well. Deploy the digest you verified, never a tag, and enforce the same identity at admission with a policy such as a Sigstore ClusterImagePolicy.

  • Anyone can produce a valid keyless signature with their own identity, so verification without an identity policy proves nothing.
  • For GitHub Actions the certificate identity is the workflow file and ref; if you must use a regular expression, anchor it with ^ and $.
  • Fulcio certificates also record the trigger, source repository, ref and runner environment, and each can be pinned.
  • A Sigstore bundle carries the certificate, the signature or DSSE envelope, and transparency-log or timestamp evidence, so verification can run offline.
  • Verify and deploy the same digest; a tag can move between the check and the pull.

Verify it

  1. Run cosign verify <image>@sha256:<digest> with the exact identity, issuer, trigger and repository flags and expect exit code 0.
  2. Change one expected value, such as --certificate-github-workflow-trigger, and confirm that verification fails.
  3. Test the ClusterImagePolicy with the policy-controller tester (-policy and -image) before applying it to the cluster.
  4. Label a test namespace with policy.sigstore.dev/include=true and confirm that an unsigned image and an image signed by another workflow are both rejected.
  5. Compare the digest in the running Pod status with the verified digest.
Applies to
  • cosign v3.1.3; the Sigstore bundle is the default format
  • Sigstore policy-controller v0.15.1, ClusterImagePolicy policy.sigstore.dev/v1beta1

Artifact verification against an explicit policy. A valid signature alone does not identify the intended release or trustworthy builder.

Part of
Learning path: From source to trusted release (step 9 of 12). Workflows: Fix a finding, Build an AppSec program

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 31 minutes: 8 to read and 23 for the exercise

What you will learn

  • Separate valid signatures from authorized signers.
  • Apply exact issuer, identity and digest predicates.
  • Prevent a mutable tag from changing the object after verification.

Before you start

Release and platform engineers who sign artifacts and enforce signature policy at promotion or admission

  • Understand digests, signatures, trust anchors and signer identity.
Go to the guided practice

Write down what a valid signature must authorize

A cryptographic signature answers whether particular bytes were signed by a key or authenticated identity. Authorization is the additional question of whether that signer is allowed to approve this artifact for this purpose. Accepting any valid signature allows an attacker to sign their own malicious artifact and pass the test. A deployment policy therefore binds artifact digest, issuer, identity, expected repository/workflow and permitted release context. Certificate identity patterns need careful anchoring; a broad organization match may authorize unrelated repositories. Fulcio certificates record more than the subject alternative name: for GitHub Actions they include the OIDC issuer, the workflow trigger, the source repository, ref and commit, and whether the runner was GitHub-hosted, so a policy can require a push to main of the right repository rather than any run of a similarly named workflow. The SolarWinds Orion and 3CX releases carried valid signatures; the builds behind them were compromised (/domains/supply-chain/builds/case-study-solarwinds/, /domains/supply-chain/builds/case-study-3cx/).

For the fictional Finch service, policy P7 permits a single release workflow identity from the GitHub Actions OIDC issuer. It also requires a trusted verifier's successful cryptographic result and an attestation subject equal to the requested deployment digest. These are conjunctive conditions. A policy file stored beside attacker-controlled input is not independently trusted. Its revision must come from the protected verifier configuration. Revoking a producer changes future authorization decisions; it does not erase the historical fact that an old signature verified under an older policy.

Authenticate first, then compare exact subject and identity

Sigstore's verification interface supports explicit certificate identity and OIDC issuer constraints. For a real container, verify an immutable image reference and keep normal claim checking enabled. A successful command is evidence only for the configured trust roots and options. Disabling claim checks to make a failing artifact pass removes the binding you intended to enforce. For detached files, verify the actual file and its bundle, not a separately supplied checksum string that an attacker can replace.

The small policy below operates only on already authenticated verifier output; it is intentionally not a signature implementation. It demonstrates that a cryptographic success flag cannot replace issuer, identity or digest checks. In production, the flag must arrive through a trusted verifier interface, not from untrusted JSON. Parse the verified statement once, reject malformed or ambiguous claims, and make the policy produce a reason for each failed predicate. A source commit or builder policy can be added as distinct requirements when those claims are part of the release decision, as /domains/supply-chain/provenance/read-and-verify-slsa/ does with build provenance.

Python
EXPECTED = {
    "issuer": "https://token.actions.githubusercontent.com",
    "identity": "https://github.com/finch/api/.github/workflows/release.yml@refs/heads/main",
    "digest": "sha256:" + "a" * 64,
}
def decide(verified):
    if verified.get("cryptographic_verification") is not True:
        return "deny: signature"
    for field, required in EXPECTED.items():
        if verified.get(field) != required:
            return "deny: " + field
    return "allow"

Verify with cosign: identity, issuer and claims

The commands below apply policy P7 with real signature verification tools. cosign verify checks the image signature, the certificate chain to the Sigstore root, the transparency-log evidence and every claim you pass; a missing or different claim fails the command. --certificate-identity must equal the certificate identity exactly, while --certificate-github-workflow-trigger and --certificate-github-workflow-repository pin claims that the identity alone does not cover. For files, cosign sign-blob --bundle writes a Sigstore bundle and cosign verify-blob --bundle checks the file against it. For artifacts attested with GitHub, gh attestation verify performs the equivalent check.

Testverify-release.shShell
# Verify the image digest you will deploy, never a tag.
IMAGE=ghcr.io/finch/api@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
cosign verify "$IMAGE" \
  --certificate-identity https://github.com/finch/api/.github/workflows/release.yml@refs/heads/main \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-github-workflow-trigger push \
  --certificate-github-workflow-repository finch/api

# A file signed with cosign sign-blob: check it against its Sigstore bundle.
cosign verify-blob finch-api.tar.gz --bundle finch-api.tar.gz.sigstore.json \
  --certificate-identity https://github.com/finch/api/.github/workflows/release.yml@refs/heads/main \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-github-workflow-trigger push

Sign and verify keylessly on GitLab

GitLab.com pipelines can sign without a stored key in the same way. A job requests an ID token with the audience sigstore through id_tokens, Cosign exchanges it for a short-lived Fulcio certificate, and the signature, certificate and transparency-log evidence go into a bundle next to the artifact. GitLab documents this for GitLab.com only; self-managed instances need their own Sigstore deployment, and the id_tokens configuration must live in the project that is built, not in an included file from another repository or a child pipeline.

The certificate identity is the instance URL, the project path, a double slash, the CI configuration path and the ref, for example https://gitlab.com/finch-group/finch//.gitlab-ci.yml@refs/heads/main, and the issuer is https://gitlab.com. Pin both, as policy P7 does for GitHub: an identity that names the ref rejects a signature made by a pipeline on another branch, and a single slash in place of the double one makes every verification fail. Build and sign in the same job, and sign the digest of what you built rather than a tag.

Configuration.gitlab-ci.ymlYAML
# .gitlab-ci.yml on GitLab.com (Cosign 2.0.1 or later)
build_and_sign:
  stage: build
  image: alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 # move to each new Alpine release
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  variables:
    COSIGN_YES: "true"
  id_tokens:
    SIGSTORE_ID_TOKEN: # GitLab's OIDC token, exchanged with Fulcio for a short-lived certificate
      aud: sigstore
  before_script:
    - apk add --no-cache cosign make # Cosign 3.0.6 from the community repository
  script:
    - make dist/finch.tar.gz
    - cosign sign-blob dist/finch.tar.gz --bundle dist/finch.tar.gz.sigstore.json
  artifacts:
    paths:
      - dist/

# Consumer: pin project, CI file and ref (note the double slash) and the issuer.
#   cosign verify-blob dist/finch.tar.gz --bundle dist/finch.tar.gz.sigstore.json \
#     --certificate-identity "https://gitlab.com/finch-group/finch//.gitlab-ci.yml@refs/heads/main" \
#     --certificate-oidc-issuer "https://gitlab.com"

Know what the bundle and the transparency log prove

Keyless artifact signing has three parts. Fulcio issues a certificate valid for 10 minutes that binds a public key to the OIDC identity of the signer, the CI run signs the artifact or an in-toto statement, and the signature is recorded in the Rekor transparency log or timestamped by a timestamp authority. Because the certificate expires almost at once, the log entry or signed timestamp is what shows the signature was made while it was valid. A Sigstore bundle packages the certificate, the signature or DSSE envelope and that log or timestamp evidence, so a verifier can work offline with a pinned trusted root.

Attestations use DSSE envelopes, whose signature covers the payload type as well as the payload, so a signed provenance statement cannot be replayed as a different kind of document. A transparency log entry does not make an artifact trustworthy; it makes the signing event publicly visible, so you can monitor the log for signatures made with your identities that you did not expect.

Enforce the policy at admission

A promotion job can be bypassed by anyone who can create Pods directly, so enforce the same identity where workloads start. Sigstore policy-controller checks images in namespaces labeled policy.sigstore.dev/include: "true" against ClusterImagePolicy resources and rejects images that match no policy unless no-match-policy is changed. The policy below accepts Finch API images only with a keyless signature from the release workflow on main. Kyverno verifyImages rules provide the same control with optional checks of additional certificate extensions. Admission policies need an availability plan: if the policy service is unavailable, decide in advance whether new Pods fail closed.

Configurationcluster-image-policy.yamlYAML
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: finch-api-release
spec:
  images:
    - glob: "ghcr.io/finch/api**"
  authorities:
    - keyless:
        url: https://fulcio.sigstore.dev
        identities:
          - issuer: https://token.actions.githubusercontent.com
            subject: https://github.com/finch/api/.github/workflows/release.yml@refs/heads/main
      ctlog:
        url: https://rekor.sigstore.dev

Make verification and deployment one identity-preserving operation

The promotion job should resolve a digest, verify that exact object, and deploy that digest. Verifying a tag and later pulling the tag reintroduces substitution if the tag changes between those operations. Authorization should be checked at the place that grants deployment, not only in a contributor-controlled test job. Where artifacts move between registries, verify content identity across the copy and preserve the statement or bundle needed by the target verifier.

Define failure behavior explicitly. A missing bundle, unknown identity or unreachable verifier is not a successful policy evaluation. Holding a release during an outage has availability cost, so plan an emergency authorization path in advance. It should record the exact digest, business decision maker, alternative evidence, expiry and follow-up; it must not become an undocumented global bypass. Preserve decision ID, time, full digest, verified identity, issuer, policy revision, verifier version and evidence location. Those fields let incident responders determine which release policy actually admitted an artifact.

Use near misses to prove that the rule is discriminating

A good positive test shows the intended release can pass. Near-miss tests show why others cannot: valid signature from another workflow, correct identity under another issuer, correct signer for different bytes, and missing verification. Keep the expected reason specific. If every failure says only bad artifact, operators may disable the check to diagnose it. Failure categories should expose enough context to repair configuration without logging credentials or treating untrusted text as terminal commands.

The fixture below allows evaluation without a registry, certificate issuance or credentials. All cryptographic results are stipulated training inputs. Running the small function on the corresponding records demonstrates only policy logic. Real integration tests must additionally verify certificate chains, trusted timestamps or log evidence as applicable to the chosen Sigstore mode, and actual image binding. The lab at /labs/artifact-policy/ and the release-verification project on this page provide related practice. A policy-logic unit test is useful evidence about comparisons; it cannot certify the whole supply chain.

Guided practice

Evaluate four authenticated-result records using P7. Use full digest A = sha256 followed by 64 a characters, and B = sha256 followed by 64 b characters.

Supplied scenario material
Plain text
P7 expected issuer = https://token.actions.githubusercontent.com
P7 expected identity = https://github.com/finch/api/.github/workflows/release.yml@refs/heads/main
P7 expected digest A = sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
R1: verification true, expected issuer, expected identity, digest A.
R2: verification true, expected issuer, identity https://github.com/finch/tools/.github/workflows/release.yml@refs/heads/main, digest A.
R3: verification true, expected issuer, expected identity, digest B.
R4: verification false, expected issuer, expected identity, digest A.
After R1 verifies, tag stable resolves to B. Deployment request still proposes stable.
Fictional policy owner: platform engineer Ren; policy revision P7 is protected.
  1. 1. Separate authentication and authority

    Explain why R2 is a useful negative test although its verification flag is true.

    Expected result and reasoning

    R2 is authenticated as the tools workflow, not authorized as the api release producer. An organization-wide identity wildcard could admit it incorrectly.

  2. 2. Evaluate the policy

    Compute the exact result string for each record.

    Result string for each record under P7

    R1
    R2
    R3
    R4

    Expected result and reasoning

    R1: allow. R2: deny: identity. R3: deny: digest. R4: deny: signature. A missing field also denies because it cannot equal the required value.

  3. 3. Check the deployment selector

    Decide whether deploying stable after R1 is authorized.

    Expected result and reasoning

    No. stable now resolves to B. The verifier authorized only A; deploy the immutable A reference or perform a new approval and verification for B.

  4. 4. Define a trusted interface

    Identify which fixture field would be dangerous to accept directly from uploaded JSON.

    Expected result and reasoning

    cryptographic_verification must be set by the protected verifier, never supplied as authoritative by the artifact producer. Issuer and identity likewise must come from authenticated certificate/statement processing.

  5. 5. Record the result and limit

    Write decision SIG-1 including policy and residual uncertainty.

    Expected result and reasoning

    SIG-1: Ren, P7, R1, digest A, expected issuer/identity, allow digest A only; stable request rejected. Residual uncertainty: this exercise does not prove a real signature verified or that A contains vulnerability-free code.

Your deliverable: Four exact policy outcomes and an identity-preserving promotion decision.

All verification flags are stipulated. The Python is a policy demonstration, not a cryptographic verifier, and must never be connected directly to unauthenticated producer input.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Repair the code

Signed artifact provenance lab: Reject a release from the wrong producer

Run a local project

Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.

Node.js 22+ · no dependencies · 60–90 min

Build and verify a signed software release

Build a real executable, generate an SBOM and DSSE-signed in-toto provenance, then repair the consuming gate so a valid signature cannot approve the wrong source, builder or release.

In the extracted folder, run:

Shell
node build.mjs
node --test exercise.test.mjs

20 regression tests · The starter intentionally fails some cases.

Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.

Actual Ed25519 signatures over the DSSE pre-authentication encoding, checked against the DSSE test vector, and executable output; there is no certificate or transparency log, the local producer and consumer are not isolated, and this does not establish a SLSA level.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. Sigstore: verifying signatures and attestations
  2. SLSA 1.2 specification. Approved, November 2025.
  3. cosign verify reference
  4. cosign verify-blob reference
  5. Fulcio certificate extensions (OID reference)
  6. Sigstore: certificate authority overview
  7. Sigstore: policy-controller overview
  8. GitHub CLI manual: gh attestation verify
  9. DSSE envelope specification
  10. GitLab Docs: Use Sigstore for keyless signing and verification
  11. OWASP Top 10 CI/CD Security Risks: CICD-SEC-9 Improper Artifact Integrity Validation. v1.0 (2022).

Reference information

SDLC phase
Release
CAPEC attack patterns

Download this guide as Markdown