Cosign is the Sigstore command-line tool for signing container images and verifying those signatures before the images are deployed. A signature proves that an image was produced by someone holding your key (or by a specific CI workflow) and that its content has not changed since. In this tutorial you will install Cosign on Ubuntu 24.04, sign an image stored in a local test registry with a key pair, attach and verify an SBOM attestation, and then set up keyless signing in GitHub Actions.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS (x86_64), for example a CubePath VPS, with a non-root user that has sudo privileges.
  • Docker Engine installed, with your user able to run docker commands.
  • For the keyless section: a GitHub repository with Actions enabled. The workflow pushes to GitHub Container Registry (GHCR), so no extra account is needed.

Cosign talks to the registry directly and stores signatures next to the image as OCI artifacts, so any OCI-compliant registry works (Docker Hub, GHCR, Harbor, a self-hosted registry).

Step 1 - Installing Cosign

Cosign is a single static binary. Download it together with the checksum file published for the same release:

cd /tmp
curl -fsSLO https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64
curl -fsSLO https://github.com/sigstore/cosign/releases/latest/download/cosign_checksums.txt

Check that the binary matches the published SHA-256 checksum before installing it:

sha256sum --ignore-missing -c cosign_checksums.txt
cosign-linux-amd64: OK

Install it to /usr/local/bin with the right permissions:

sudo install -m 0755 cosign-linux-amd64 /usr/local/bin/cosign

Confirm that the shell finds it:

cosign version

The output lists the version (GitVersion), the git commit and the build date. On an arm64 server, download cosign-linux-arm64 instead.

Step 2 - Starting a local test registry

Signing needs an image in a registry, because the signature is pushed to the same repository. To practise without touching a production registry, run the official registry image and bind it to the loopback interface only:

docker run -d --name registry --restart unless-stopped -p 127.0.0.1:5000:5000 registry:2

Pull a small image, tag it for the local registry and push it:

docker pull alpine:3.20
docker tag alpine:3.20 localhost:5000/demo/app:1.0
docker push localhost:5000/demo/app:1.0

The last line of the push output contains the image digest:

1.0: digest: sha256:0a4eaa0eecf5f8c050e5bba433f58c052be7587ee8af3e8b3910ef9ab5fbe9f5 size: 528

Tags are mutable, digests are not, so you will always sign and verify by digest. Store the full reference in a variable, replacing the hash with the one from your own output:

IMAGE=localhost:5000/demo/app@sha256:0a4eaa0eecf5f8c050e5bba433f58c052be7587ee8af3e8b3910ef9ab5fbe9f5

Cosign accepts plain HTTP for localhost registries, so no TLS setup is needed for this test.

Step 3 - Generating a signing key pair

Key-based signing is the simplest model and works in air-gapped or private environments. Create a directory for the keys and generate them:

mkdir -p ~/cosign && cd ~/cosign
cosign generate-key-pair

Cosign asks for a password that encrypts the private key. Use a long, unique password and store it in your password manager:

Enter password for private key:
Enter password for private key again:
Private key written to cosign.key
Public key written to cosign.pub

Restrict the private key to your user:

chmod 600 cosign.key

cosign.key must stay secret; in CI it belongs in the secret store, never in the repository. cosign.pub is safe to distribute to anyone who needs to verify your images.

Step 4 - Signing an image with the key

Sign the image by digest. The -a flags add annotations, which are signed together with the image and can be checked later:

cosign sign --key cosign.key \
  -a env=staging \
  -a git-commit=abc1234 \
  "$IMAGE"

Cosign asks for the key password and then for confirmation before uploading an entry to the public Rekor transparency log. Type y to continue:

Enter password for private key:
...
Are you sure you would like to continue? [y/N] y
tlog entry created with index: 312345678
Pushing signature to: localhost:5000/demo/app

For non-interactive use, such as a CI job, provide the password through the COSIGN_PASSWORD environment variable and add --yes to skip the confirmation prompt.

Step 5 - Verifying the signature

Anyone with the public key can now verify the image:

cosign verify --key cosign.pub "$IMAGE"
Verification for localhost:5000/demo/app@sha256:0a4eaa0e... --
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 signatures were verified against the specified public key

[{"critical":{"identity":{"docker-reference":"localhost:5000/demo/app"},"image":{"docker-manifest-digest":"sha256:0a4eaa0e..."},"type":"cosign container image signature"},"optional":{"env":"staging","git-commit":"abc1234",...}}]

The command exits with status 0 on success, so it can gate a deployment script. You can also require specific annotations, which makes verification fail for images signed for another environment:

cosign verify --key cosign.pub -a env=staging "$IMAGE" > /dev/null && echo "signature OK"
signature OK

To see a failure, push an unsigned image and verify it:

docker tag alpine:3.20 localhost:5000/demo/unsigned:1.0
docker push localhost:5000/demo/unsigned:1.0
cosign verify --key cosign.pub localhost:5000/demo/unsigned:1.0
Error: no signatures found

Step 6 - Attaching a signed SBOM attestation

An attestation is a signed statement about an image, such as a Software Bill of Materials (SBOM) or build provenance. Generate a CycloneDX SBOM for the image with the official Syft container, so nothing extra has to be installed on the host:

docker run --rm anchore/syft:latest alpine:3.20 -o cyclonedx-json > sbom.cdx.json

Check that the file contains components:

grep -c '"bom-ref"' sbom.cdx.json

The number printed is the count of packages and files Syft found. Attach the SBOM to the image as a signed attestation:

cosign attest --key cosign.key --type cyclonedx --predicate sbom.cdx.json "$IMAGE"

Verify the attestation. Cosign checks the signature and prints the attestation envelope, whose payload is base64-encoded JSON:

cosign verify-attestation --key cosign.pub --type cyclonedx "$IMAGE" > attestation.json

The verification summary is printed to the terminal, the same way as in Step 5. Consumers can now fetch a trustworthy SBOM for exactly this digest instead of trusting a file from a download page.

Step 7 - Keyless signing in GitHub Actions

Keyless signing removes the private key entirely. In CI, Cosign exchanges the workflow's OIDC token for a short-lived certificate from Sigstore's Fulcio CA. The certificate records which workflow, repository and branch produced the signature, and verification checks that identity instead of a public key.

Create .github/workflows/build-and-sign.yml in your repository:

name: Build and sign

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write
  id-token: write   # lets the job request an OIDC token for Sigstore

jobs:
  build-and-sign:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: sigstore/cosign-installer@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: build
        uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

      - name: Sign the image by digest
        env:
          IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
        run: cosign sign --yes "$IMAGE"

The repository needs a Dockerfile at its root. GHCR requires lowercase repository names, so if your GitHub owner or repository name contains uppercase letters, set a lowercase image name explicitly in tags and IMAGE.

Push to main and open the workflow run. The signing step prints a tlog entry created with index line. To verify the image from your server, pin both the workflow identity and the OIDC issuer. Replace your_org/your_repo with your repository:

cosign verify \
  --certificate-identity "https://github.com/your_org/your_repo/.github/workflows/build-and-sign.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/your_org/your_repo@sha256:your_digest

The checks listed in the output now include The code-signing certificate was verified using trusted certificate authority certificates. A signature made from a fork, another branch or another workflow file fails this check, which is exactly the guarantee you want.

Step 8 - Enforcing signatures at deploy time

Verification only protects you if something runs it before an image starts. The two common places are:

  • Deployment scripts or CI jobs: run cosign verify against the digest you are about to deploy and stop on a non-zero exit code.
  • Kubernetes admission control: a policy engine such as Kyverno rejects Pods whose images lack a valid signature, using the same public key or keyless identity you used above.

Whichever you choose, deploy by digest, not by tag, so that the image that runs is the one that was verified.

Troubleshooting

Error: no signatures found: the image was never signed, or it was signed under a different repository name. Signatures are stored per repository, so an image copied to another registry must be copied together with its signatures (cosign copy does this) or signed again.

none of the expected identities matched: the keyless certificate belongs to a different workflow, branch or repository than the one in --certificate-identity. Run the verify command once with --certificate-identity-regexp '^https://github.com/your_org/', read the identity in the certificate details, then pin the exact value.

Keyless signing fails in CI with an OIDC or token error: the job is missing id-token: write in its permissions block. Workflows triggered from forks do not get OIDC tokens.

The job hangs at the password prompt: set COSIGN_PASSWORD from a CI secret and pass --yes so Cosign never waits for input.

Conclusion

You installed Cosign with a verified checksum, signed and verified an image by digest with a key pair, attached a signed SBOM attestation and set up keyless signing tied to a specific GitHub Actions workflow. As next steps, enforce signatures in your cluster with an admission policy engine such as Kyverno, add build provenance attestations to your pipeline, and remove the local test registry with docker rm -f registry when you are done.