All writing
Updated 7 min readNaren

Sign and verify your first container image with Cosign

From an unsigned image to a keyless signature anyone can verify, using Sigstore's public infrastructure. No key to lose.

A tag is a label somebody can move. api-gateway:v2.1.0 points at whatever the last person with push access told the registry it points at, and docker pull fetches that without complaint.

The pull looks the same whether that person shipped the build you expected or repointed the tag at something else, and the same again if the registry itself served you different bytes.

A digest cannot move: it is the SHA-256 hash of the image's manifest, the small file that lists the layers making up the image.

Written out, it reads sha256:abc123..., and that hash names exactly one image.

By the end of this you will have pushed an image, signed it by digest without creating a key, verified the signature by naming who you expect to have signed it, and attached a signed inventory of its contents.

Then you will do all of it again in GitHub Actions. You need Docker, a GitHub account and half an hour.

Install Cosign

Cosign is the command-line tool for Sigstore, a set of free public services that let anyone sign software without owning a signing key.

Sigstore runs under the Open Source Security Foundation (OpenSSF), part of the Linux Foundation, where it graduated in March 2024. On macOS:

brew install cosign
cosign version

On Linux, take the binary that matches your CPU:

# amd64 for Intel/AMD, arm64 for Graviton, a Pi, or an ARM VM
curl -O -L "https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64"
sudo mv cosign-linux-amd64 /usr/local/bin/cosign
sudo chmod +x /usr/local/bin/cosign
cosign version

Swap amd64 for arm64 on an ARM machine. If you do not, the binary fails with an Exec format error that does not mention the architecture.

cosign version should report v3.1.3, released 6 August 2026.

Anything below v3.1.0 records signed statements about an image in a legacy format that current Sigstore code cannot read, so checking one of those statements later in this post fails.

Push an image, so there is something to sign

Cosign signs images that already exist in a registry, not local ones. An OCI registry is the server that stores container images: ghcr.io, Docker Hub, ECR.

OCI, the Open Container Initiative, is the standards body whose specifications define the formats they all implement.

You need a GitHub personal access token carrying the write:packages scope; create one under Settings, Developer settings on github.com, and paste it when docker login asks for a password.

Any two-line Dockerfile will do, so save this one next to where you are working:

Dockerfile
FROM alpine
CMD ["echo", "signed"]
docker login ghcr.io -u OWNER          # prompts for the token you just created
docker build -t ghcr.io/OWNER/REPO:TAG .
docker push ghcr.io/OWNER/REPO:TAG

OWNER is your GitHub username or org, lowercase; REPO and TAG are yours to pick. Cosign does not modify your image when it signs it. It pushes a new object into the same repository, which is why the write:packages scope covers signing as well as pushing.

Turn the tag into a digest

Everything from here wants a digest reference: ghcr.io/owner/repo@sha256:..., the address that names the image by its hash rather than by its movable tag.

IMAGE="ghcr.io/OWNER/REPO:TAG"
DIGEST=$(docker buildx imagetools inspect "$IMAGE" | awk '/^Digest:/ {print $2}')
REF="ghcr.io/OWNER/REPO@${DIGEST}"
echo "$REF"

$REF now reads ghcr.io/OWNER/REPO@sha256: plus 64 hex characters; if it does not, the push did not land. Cosign v3 warns when you sign a tag, so $REF is what every command below gets.

Sign once with a key

Public key infrastructure, PKI, is the traditional arrangement: generate a long-lived key pair, guard the private half for years, and have a certificate authority certify that the public half is yours.

The certificate authority is a party both sides already trust. Cosign still works this way; do it once to see what keyless signing removes.

If key pairs are new, how a private key signs is the background.

cosign generate-key-pair

The first thing on screen is a password prompt, not the two files it writes. COSIGN_PASSWORD supplies one non-interactively; never commit cosign.key.

cosign sign   --key cosign.key --tlog-upload=false        "$REF"
cosign verify --key cosign.pub --insecure-ignore-tlog=true "$REF"

Those flags are a matched pair. Cosign v3 uploads every signature to the public transparency log by default, as its v3.0.0 changelog states. Without --tlog-upload=false, a throwaway key's fingerprint lands in a permanent public record.

And once you skip that upload, a bare cosign verify --key fails looking for a log entry that was never written, which is what --insecure-ignore-tlog=true answers.

Drop both or neither. Now delete both files.

What keyless signing does

Keyless signing means you never create or hold a private key. You prove who you are to an OIDC provider. OIDC is OpenID Connect, the log-in-with-Google standard.

Cosign hands the identity token that provider issues to Fulcio, Sigstore's free code-signing certificate authority. Fulcio trades it for an X.509 certificate, the format that also secures HTTPS, bound to that identity and valid for ten minutes.

Cosign signs, records the signature and certificate in Rekor, Sigstore's public transparency log, then pushes the signature to your registry.

The ten-minute lifetime is deliberate. Fulcio's documentation says it was built to avoid revocation by issuing short-lived certificates instead, so there is no key to leak, rotate or revoke.

An expired certificate is still worth something because of the log, a public, append-only record whose structure lets anyone prove nothing was inserted, removed or altered after the fact.

Watch the certificate arrow and the Rekor arrow below.

Cosign gets an identity token from an OIDC provider, trades it with Fulcio for a ten minute certificate, records the signature in Rekor, then pushes the signature to the registry as a separate artifact. Browser login Identity token Identity token Certificate, valid 10 minutes Signature and certificate Log entry, public and permanent Signature as separate artifact cosign OIDC provider Fulcio Rekor Registry

All of that is one command:

cosign sign "$REF"

It opens a browser so you can pick a provider from Google, GitHub, Microsoft and GitLab. Note which you pick: verification has to name it.

Before uploading, Cosign stops and asks you to confirm. It warns that identifying information will be stored in public transparency logs and cannot be removed later. That includes the email address of the account you authenticate with.

-y skips the prompt; type it only once you are content to publish that address.

Verify it by naming who you expect

cosign verify \
  --certificate-identity "you@example.com" \
  --certificate-oidc-issuer "https://accounts.google.com" \
  "$REF"

In place of you@example.com, put the address on the account you just authenticated with. Both flags are mandatory.

Since v3.0.0, Cosign refuses a keyless verify that does not pin an identity and an issuer; in v1 you could ask whether an image was signed without saying by whom, and that is now a hard error.

Use the issuer you logged in with: Google https://accounts.google.com, GitHub https://github.com/login/oauth, Microsoft https://login.microsoftonline.com, GitLab https://gitlab.com. Get the pair wrong and a signed image fails to verify.

What a passing verify proves

That one specific OIDC identity signed one specific digest, and that you chose to trust that identity. Not that the image is safe. Sigstore's own security model is explicit that a compromised OIDC account, or a compromised Fulcio, can produce a certificate that verifies exactly like yours. It is equally explicit that misbehaviour by Fulcio or Rekor might go undetected if no third party monitors the logs.

See where the signature went

cosign tree "$REF"

That lists everything Cosign has attached to the image. It is the only command here that makes the registry side visible. The image itself does not change.

Signatures are registry artifacts, blobs a registry stores under OCI's rules that are not images. Cosign's README is blunt about them: they are separate objects with only a weak reference back to what they sign. They live in the same repository by default, and docker pull leaves them behind.

Attest an SBOM, and do not attach one

An SBOM, a software bill of materials, is a machine-readable inventory of every package and library inside your image.

When a vulnerability lands, it lets you answer whether you are affected. Why anyone wants an ingredients list covers what it does not buy you.

Cosign does not generate one. Syft does, and getting one out of an image shows how.

brew install syft   # or see https://github.com/anchore/syft for Linux install
syft scan "$REF" -o cyclonedx-json=sbom.cdx.json

cosign attest --yes \
  --type cyclonedx \
  --predicate sbom.cdx.json \
  "$REF"

An attestation is a signed statement about an artifact rather than the artifact itself: this image's SBOM is exactly this file, and I vouch for that.

The claim inside is called the predicate; here it is the SBOM file you just generated. --type cyclonedx tells verifiers how to read it.

CycloneDX is one of the two widely used SBOM formats, at specification 1.7; SPDX is the other.

Cosign wraps the statement in an in-toto envelope, in-toto being the specification that fixes what a signed statement about an artifact has to contain.

--yes is fine here only because you read that prompt once already.

cosign attach sbom does not sign anything

cosign attach sbom still exists in Cosign v3, marked DEPRECATED in the source, and prints WARNING: Attaching SBOMs this way does not sign them. at runtime. It uploads a blob next to your image and stops: a file in a registry with no evidence it describes that image or came from you. Cosign slated it for removal in a release soon after 22 February 2024.

Verification takes the same identity and issuer, because the same identity signed it:

cosign verify-attestation \
  --type cyclonedx \
  --certificate-identity "you@example.com" \
  --certificate-oidc-issuer "https://accounts.google.com" \
  "$REF"

The same thing in CI

Locally you clicked a browser. A GitHub Actions runner has an OIDC token of its own, so the identical cosign sign runs with no key material and no browser.

.github/workflows/sign.yml
name: Build, Sign, and Attest
on:
  push:
    branches: [ "main" ]

jobs:
  build-and-sign:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write        # without this line, keyless signing fails
    steps:
      - name: Checkout code
        uses: actions/checkout@v7

      - name: Install Cosign
        uses: sigstore/cosign-installer@v4.1.2   # no moving @v4 tag exists; pin the patch
        with:
          cosign-release: 'v3.1.3'               # v4.1.2 defaults to v3.0.6; attest needs v3.1.0+

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Build and push image
        id: build-push
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

      - name: Sign image by digest (keyless)
        env:
          REF: ghcr.io/${{ github.repository }}@${{ steps.build-push.outputs.digest }}
        run: cosign sign --yes "$REF"

      - name: Generate CycloneDX SBOM
        env:
          REF: ghcr.io/${{ github.repository }}@${{ steps.build-push.outputs.digest }}
        run: |
          curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
            | sh -s -- -b /usr/local/bin
          syft scan "$REF" -o cyclonedx-json=sbom.cdx.json

      - name: Attest the SBOM
        env:
          REF: ghcr.io/${{ github.repository }}@${{ steps.build-push.outputs.digest }}
        run: |
          cosign attest --yes \
            --type cyclonedx \
            --predicate sbom.cdx.json \
            "$REF"

Four lines there are easy to get wrong. id-token: write is the permission that lets the workflow fetch an OIDC token, so copying a subset of that block breaks signing silently.

sigstore/cosign-installer publishes v4.0.0 through v4.1.2 and no moving @v4 tag, so @v4 fails the run while the stale @v3 installs an old Cosign. cosign-release is explicit because v4.1.2 defaults to Cosign v3.0.6, below what attest needs.

And --yes is required in CI: there is no terminal to answer the prompt.

Verify what CI signed, with the regexp anchored

Identity in CI is not an email address. Fulcio puts the workflow reference in the certificate, shaped https://github.com/OWNER/REPO/.github/workflows/WORKFLOW.yml@refs/heads/BRANCH, which is why CI verification uses the regexp form of the flag.

cosign verify \
  --certificate-identity-regexp '^https://github\.com/OWNER/REPO/\.github/workflows/.+@refs/heads/main$' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
  ghcr.io/OWNER/REPO@sha256:REPLACE_WITH_DIGEST

Substitute your owner, repo and the digest that workflow pushed. The ^, the $ and the escaped dots each matter.

In pkg/cosign/verify.go, Cosign compiles your pattern and calls Go's regexp.MatchString on each name in the certificate, which is a substring test with no anchoring of its own.

Unanchored, https://github.com/OWNER/REPO/.* is satisfied by any identity that merely contains it, and an unescaped dot matches any character. Most tutorials I have read get this wrong; the fix costs one ^ and one $.

Where this stops

You have a signed image, a signed SBOM and a command that checks both, and none of it prevents anything.

Nothing rejects an unsigned image until something checks at the moment of use, and in Kubernetes that is an admission controller: the component that inspects every pod before it starts and can refuse it.

Kyverno, a policy engine that runs inside a Kubernetes cluster, can check Cosign signatures at that point and turn a failed verify into a rejected pod.

Two limits are better met here than in production. The first is the network.

Public Fulcio and Rekor are internet services, and an air-gapped machine has no route to the internet at all, so a build on one cannot reach them. That build signs instead with a key held in a KMS, a key management service like AWS KMS or HashiCorp Vault that signs on your behalf so the key never reaches your machine.

The second is what a signature covers. It says who signed, and nothing about how the build was run. SLSA v1.2, the OpenSSF framework of levelled requirements for build processes, grades that separately. Signing is the easy half.