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.pemThe 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-issueris alwayshttps://frem.sh/_app/runner/oidcfor a fremforge Actions job.--certificate-identityishttps://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-timestampstakes the signing time from thetsa.frem.shtimestamp, and--insecure-ignore-tlogskips the transparency-log check, because there is no Rekor.--insecure-ignore-sctis 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 timetsa.frem.shsigned.
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.
| Setting | Meaning |
|---|---|
enabled | Check signatures when an image is pulled. |
mode | shadow (the default): serve the image and record a would-block. enforce: refuse the pull with 403. |
signers | Trusted 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. |
keys | Trusted public keys: ECDSA P-256 or P-384, Ed25519, or RSA of at least 2048 bits. |
scope | Which images it applies to: image names in your organisation, optionally with a tag (api, team/*, api:prod-*). Empty means every image. |
trust_public_sigstore | Also 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… | shadow | enforce |
|---|---|---|
| has a signature from a trusted signer | served | served |
| is not signed | served, registry.pull.would_block | 403 DENIED, registry.pull.blocked |
| is signed, but not by a trusted signer | served, would-block | 403, blocked |
| has a signature that does not verify, or that names another image | served, would-block | 403, blocked |
| cannot be checked (the registry did not answer) | served, logged | 503 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
- Turn the check on in
shadowand give it a few days. - Read the would-blocks in the audit log (
registry.pull.would_blockwithgate: signature) or in Recent signature checks on the settings page. - Sign what is missing, narrow
scopeif needed, then switch toenforce.
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.blockedorregistry.pull.would_blockwithgate: signatureandreason(unsigned,untrustedorinvalid), 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 thesecurity.pull.blockedalert 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
- Image scanning and Malicious packages: the other pull checks.
- SBOMs.