Skip to main content

Image signatures: sign with cosign, enforce at pull

fremforge checks the cosign signature of every container image your organisation pulls from frem.sh. You decide whose signatures you trust, and whether a pull without one is recorded (shadow) or refused (enforce).

The check runs on the exact image digest being served, at the moment of the pull, so it applies equally to docker pull, Kubernetes, CI jobs and any other OCI client.

Sign an image in fremforge Actions

fremforge runs its own Sigstore: a certificate authority (Fulcio) at sign.frem.sh and an RFC 3161 timestamp authority at tsa.frem.sh. It runs no transparency log (no Rekor) and no certificate transparency log: the timestamp from tsa.frem.sh is what proves when the image was signed.

The trust root

The root certificate of sign.frem.sh and the certificate chain of tsa.frem.sh are published at https://www.frem.sh/.well-known/slsa-trust-root.json, as keyless.fulcio_root_ca_pem and keyless.tsa_cert_chain_pem. Fetch them once and commit them to the repository that signs or verifies: a hosted runner reaches sign.frem.sh and tsa.frem.sh, but not www.frem.sh.

curl -sSf https://www.frem.sh/.well-known/slsa-trust-root.json -o slsa-trust-root.json
jq -r .keyless.fulcio_root_ca_pem slsa-trust-root.json > fulcio-root.pem
jq -r .keyless.tsa_cert_chain_pem slsa-trust-root.json > tsa-chain.pem

The root’s subject is CN=fremforge sigstore Root CA, O=fremverk, C=DE.

A job signs with its own short-lived identity, so there is no signing key to store or rotate. Ask the runner’s OIDC endpoint for a token with the audience sigstore. The certificate then names your workflow:

https://frem.sh/<org>/<repo>/.forgejo/workflows/<file>@<ref>

with the issuer https://frem.sh/_app/runner/oidc. The workflow needs the top-level enable-openid-connect: true (Forgejo does not derive it from permissions) and the job id-token: write:

enable-openid-connect: true

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Sign the image
        env:
          IMAGE: frem.sh/acme/api@${{ env.DIGEST }}
          TOKEN: ${{ secrets.FORGEJO_TOKEN }}
        run: |
          set -euo pipefail
          ID_TOKEN=$(curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sigstore" | jq -r .value)

          # fulcio-root.pem and tsa-chain.pem: committed from the published
          # trust root (see "The trust root" above).
          cosign trusted-root create \
            --fulcio "url=https://sign.frem.sh,certificate-chain=fulcio-root.pem" \
            --tsa "url=https://tsa.frem.sh/api/v1/timestamp,certificate-chain=tsa-chain.pem" \
            --out trusted-root.json
          cosign signing-config create \
            --fulcio="url=https://sign.frem.sh,api-version=1,start-time=2026-01-01T00:00:00Z,operator=frem.sh" \
            --tsa="url=https://tsa.frem.sh/api/v1/timestamp,api-version=1,start-time=2026-01-01T00:00:00Z,operator=frem.sh" \
            --tsa-config="EXACT:1" --out signing-config.json

          echo "$TOKEN" | cosign login frem.sh -u ci --password-stdin
          cosign sign --yes --signing-config signing-config.json \
            --trusted-root trusted-root.json --identity-token "$ID_TOKEN" "$IMAGE"

cosign is installed in the hosted runner image. Sign the digest (image@sha256:…), not a tag: the signature is bound to the digest.

Verify a signature

Build trusted-root.json with the same cosign trusted-root create command, then name the exact workflow that must have signed:

cosign verify --trusted-root trusted-root.json \
  --use-signed-timestamps --insecure-ignore-tlog --insecure-ignore-sct \
  --certificate-oidc-issuer https://frem.sh/_app/runner/oidc \
  --certificate-identity https://frem.sh/acme/api/.forgejo/workflows/release.yaml@refs/heads/main \
  frem.sh/acme/api@sha256:…
  • --certificate-oidc-issuer is always https://frem.sh/_app/runner/oidc for a fremforge Actions job.
  • --certificate-identity is https://frem.sh/<org>/<repo>/.forgejo/workflows/<file>@<ref>, with <ref> the full ref the job ran on (refs/heads/main, refs/tags/v1.2.0). To accept every tag, use --certificate-identity-regexp '^https://frem\.sh/acme/api/\.forgejo/workflows/release\.yaml@refs/tags/v.+$'.
  • --use-signed-timestamps takes the signing time from the tsa.frem.sh timestamp, and --insecure-ignore-tlog skips the transparency-log check, because there is no Rekor.
  • --insecure-ignore-sct is required because there is no certificate transparency log, so the certificates carry no signed certificate timestamp (SCT). What that costs: no public log in which a wrongly issued certificate would show up. The certificate must still chain to the fremforge root above, carry the exact issuer and identity you name, and fall within its validity at the time tsa.frem.sh signed.

Other ways to sign also work:

  • A key pair you manage: cosign sign --key cosign.key. Add the public key (cosign.pub) to the trust policy.
  • GitHub Actions keyless (public Sigstore): turn on Also trust the public Sigstore. It is verified offline against a pinned trust root; nothing is sent to sigstore.dev when an image is pulled.

Both cosign formats are read: the default bundle format of cosign v3 (stored under the sha256-<digest> tag, because the registry has no referrers API) and the older .sig format.

The trust policy

Set it in Security › Policies › Image policies › Image signatures at pull, with the API, or with Terraform or OpenTofu.

SettingMeaning
enabledCheck signatures when an image is pulled.
modeshadow (the default): serve the image and record a would-block. enforce: refuse the pull with 403.
signersTrusted keyless signers, one list. Each is an issuer alone, which trusts every subject that issuer certifies, or an issuer and a subject_regex that the whole certificate subject must match.
keysTrusted public keys: ECDSA P-256 or P-384, Ed25519, or RSA of at least 2048 bits.
scopeWhich images it applies to: image names in your organisation, optionally with a tag (api, team/*, api:prod-*). Empty means every image.
trust_public_sigstoreAlso trust signatures made with the public Sigstore.

A pull passes when at least one signature of the served digest verifies and its signer is trusted: one of your keys, or a certificate that matches a signer. If signers and keys are both empty, only your own organisation’s fremforge Actions jobs are trusted: a certificate from fremforge’s Sigstore with the issuer https://frem.sh/_app/runner/oidc and a subject that starts with https://frem.sh/<your org>/. Name the workflows you mean as soon as you can.

Scope by tag also covers pulls by digest. A digest names no tag, so an entry like api:prod-* applies to every api@sha256:… pull too. Otherwise a pull by digest would be a way around it.

In the console, Trusted signers is one text field: one signer per line, the issuer URL, then optionally a space and the subject regex. An issuer alone trusts everything it certifies, so for a public issuer such as GitHub Actions or fremforge Actions always add a subject. A list the page refuses comes back with the reason and what you typed, and nothing is saved.

A subject regex must not repeat a group that itself repeats (for example (a+)+), and may not use back-references or lookaround: such patterns can take exponential time, and they are refused when you save.

What a pull gets

The digest…shadowenforce
has a signature from a trusted signerservedserved
is not signedserved, registry.pull.would_block403 DENIED, registry.pull.blocked
is signed, but not by a trusted signerserved, would-block403, blocked
has a signature that does not verify, or that names another imageserved, would-block403, blocked
cannot be checked (the registry did not answer)served, logged503 with Retry-After

The 403 says which case it was, names the signer when there is one, and links to the settings page.

Two kinds of manifest are not checked: the signatures, attestations and SBOMs that are attached to images (so cosign verify itself keeps working), and other OCI artifacts such as Helm charts. This is decided from the manifest itself, not from its tag.

Multi-platform images. cosign normally signs the image index. When an index passes, its platform manifests pass too, so the follow-up pull of linux/amd64 by digest is served.

A verified result is reused for an hour, and a failed one for a minute, so a signature you push after a refusal counts within a minute. Changing the trust policy takes effect at once.

Roll it out

  1. Turn the check on in shadow and give it a few days.
  2. Read the would-blocks in the audit log (registry.pull.would_block with gate: signature) or in Recent signature checks on the settings page.
  3. Sign what is missing, narrow scope if needed, then switch to enforce.

API

GET and PUT /orgs/{org}/image-signature-policy at https://frem.sh/_app/api/v1, with policy:read and policy:write. PUT replaces the whole policy: a field you leave out gets its default.

Until 2026-10-16 trusted signers were two fields, issuers (an issuer with any subject) and identities (an issuer with a subject regex). Both still work and GET still returns them, next to signers. A PUT may send either form, or all three when they agree, so a read-modify-write of the old fields keeps working. Fields that disagree are refused with 400 invalid-value.

curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  "https://frem.sh/_app/api/v1/orgs/acme/image-signature-policy" \
  -d '{"enabled": true, "mode": "shadow",
       "signers": [{"issuer": "https://frem.sh/_app/runner/oidc",
                    "subject_regex": "https://frem\\.sh/acme/[^/]+/\\.forgejo/workflows/release\\.yaml@refs/tags/v.+"}],
       "scope": ["api", "worker:prod-*"]}'

An invalid policy is refused with 400 invalid-value, naming the field.

Audit and SIEM

  • Every change: security_policy.image_signature.updated, with what changed.
  • Every decision: registry.pull.blocked or registry.pull.would_block with gate: signature and reason (unsigned, untrusted or invalid), at most once an hour per image digest, with the previous hour’s count. These reach your SIEM stream like every other audit event, and a block also triggers the security.pull.blocked alert webhook.

Signature findings in pull requests

On every pull request, fremforge checks the signatures of the container images your repository pins by digest, that is an image: line ending in @sha256:…. The check looks in YAML files, Helm templates, Dockerfiles and Containerfiles, and covers up to 50 images per scan. The result is the fremforge/cosign-verify status on the pull request and the Cosign column on the Container images page.

Each image gets one of these results:

  • Signed: a signature verified, and the signer is shown. Two kinds of signer are trusted:
    • your organisation’s own fremforge Actions workflows, signed through fremforge’s Sigstore and checked on the trusted timestamp;
    • a signer in the public Sigstore, checked against its transparency log.
  • Not signed: the registry answered and holds no signature from a trusted signer. A signature made by another organisation’s workflow counts as not signed.
  • No trusted timestamp: your organisation’s own workflow signed the image through fremforge’s Sigstore, but without the trusted timestamp. Such a signature cannot be checked once its short-lived certificate expires, so it counts as not signed. Re-sign with the signing configuration in Sign an image in fremforge Actions.
  • Could not check: the check could not read the signature. The Container images page shows the reason, for example that the registry is not served by your dependency proxy, that the image is in another organisation’s registry, or that the registry did not answer. A could not check result is never reported as not signed.

The check reads images only through your organisation’s dependency proxy and your organisation’s frem.sh registry. It verifies offline, so it never contacts the image’s registry or Sigstore directly. Both cosign formats are read.

The status is informational: it always passes and never blocks a merge. The pull check above is a separate check.

Images that fremforge serves from its own platform registry show “signed by fremforge” when fremforge’s release build signed them. For those images, that build is the only trusted signer, and it is never trusted for your own images.

See also