diff --git a/config.json b/config.json
index 878cca0..a1eee57 100644
--- a/config.json
+++ b/config.json
@@ -48,6 +48,7 @@
         "CONTAINER_TAGS" : ["latest"],
         "CONTAINER_TAG_CMDS" : [],
         "CONTAINER_IMAGE_MAP" : {},
+        "CONTAINER_COSIGN_KEY" : "",
         "extravars" : [
             "SANITY_TESTED_DISTROS = ''",
             "BB_HASHSERVE = '${AUTOBUILDER_HASHSERV}'",
diff --git a/scripts/run-push-containers b/scripts/run-push-containers
index c13f493..9f6f2fa 100755
--- a/scripts/run-push-containers
+++ b/scripts/run-push-containers
@@ -15,6 +15,9 @@
 #   CONTAINER_TAG_CMDS       - extra shell to populate _EXTRA_TAGS
 #   CONTAINER_VERSION_RECIPE - recipe whose PV provides the version tag
 #   CONTAINER_AUTH_CONFIG    - registry auth file staged into the guest
+#   CONTAINER_COSIGN_KEY     - cosign private key path; when set, each
+#                              successfully pushed image is signed with
+#                              cosign (otherwise signing is skipped)
 #
 
 import subprocess
@@ -53,6 +56,10 @@ if not auth_config:
     else:
         auth_config = "${HOME}/.docker/config.json"
 
+# Signing is conditional: only when CONTAINER_COSIGN_KEY points at a cosign
+# private key. When unset, no cosign sysroot is prepared and no signing runs.
+cosign_key = utils.getconfigvar("CONTAINER_COSIGN_KEY", ourconfig, args.target, args.stepnum)
+
 utils.printheader("Pushing container images %s" % list(container_images.keys()))
 
 script = [
@@ -124,6 +131,99 @@ script = [
     "    _SKOPEO_READY=1",
     "}",
 ]
+
+# Cosign signing helpers, only emitted when a signing key is configured.
+#
+# prepare_cosign() is lazy like prepare_skopeo()/start_vm(): it populates the
+# cosign-native sysroot once and logs cosign in to every target registry. The
+# push paths authenticate via the auth file directly (--dest-authfile /
+# --config), but cosign authenticates through its own credential store, so we
+# must 'cosign login' explicitly. CONTAINER_REGISTRIES entries carry a
+# namespace path (e.g. quay.io/ticotimo), but the auth file is keyed by the
+# bare registry host and 'cosign login' only accepts a host authority (a path
+# fails with "registries must be valid RFC 3986 URI authorities"). So we strip
+# each entry to its host ('${reg%%/*}') for both the credential lookup and the
+# login: read auths.<host>.auth from the auth file, base64-decode it into
+# 'user:password', and feed the password on stdin (--password-stdin) so it
+# never appears in the process table.
+#
+# COSIGN_PASSWORD is exported (defaulting to empty) so cosign reads the
+# passphrase from the environment and never falls through to an interactive
+# prompt under the autobuilder (no PTY) — without it cosign tries to read the
+# passphrase from the terminal and dies with "inappropriate ioctl for device".
+# The real value, if the key is encrypted, must be present in the build
+# environment.
+#
+# sign_image() signs by digest ($2) so cosign does not warn about signing a
+# mutable tag, while still pinning the index a consumer verifies. The digest
+# MUST be the digest the tag resolves to:
+#   - multi-arch: the manifest-list/index digest, captured at push time from
+#     'skopeo copy --all --digestfile' (the digest of the list it pushed).
+#   - single-arch: the lone manifest digest, which 'skopeo inspect' returns
+#     correctly (no list, so no platform ambiguity) — used when $2 is empty.
+# 'cosign sign --recursive' then signs that digest and every child manifest.
+#
+# IMPORTANT: do NOT feed 'skopeo inspect' a multi-arch tag for the sign digest.
+# Without --raw it resolves to the host platform's CHILD manifest, so signing
+# image@<that-digest> signs one architecture and leaves the index unsigned —
+# why signed multi-arch images can still show as "Unsigned". Hence the index
+# digest comes from --digestfile, not inspect.
+if cosign_key:
+    login_block = """    for _reg in %s; do
+        _host=${_reg%%%%/*}
+        _creds=$(python3 -c 'import base64,json,os,sys
+a=json.load(open(os.path.expandvars(sys.argv[1])))
+e=a.get("auths",{}).get(sys.argv[2]) or {}
+t=e.get("auth")
+sys.stdout.write(base64.b64decode(t).decode() if t else "")' "%s" "$_host")
+        if [ -z "$_creds" ]; then
+            echo "WARNING: no credentials for $_host in auth file, skipping cosign login"
+            continue
+        fi
+        _cuser=${_creds%%%%:*}
+        _cpass=${_creds#*:}
+        printf '%%s' "$_cpass" | oe-run-native cosign-native cosign login "$_host" -u "$_cuser" --password-stdin
+    done""" % (" ".join(registries), auth_config)
+    script += [
+        "export COSIGN_PASSWORD=\"${COSIGN_PASSWORD:-}\"",
+        "_COSIGN_READY=0",
+        "prepare_cosign() {",
+        "    if [ \"$_COSIGN_READY\" = 1 ]; then return 0; fi",
+        "    bitbake cosign-native -c addto_recipe_sysroot",
+        login_block,
+        "    _COSIGN_READY=1",
+        "}",
+        # Track digests already signed in this run so the same artefact is
+        # signed only once (see sign_image).
+        "_SIGNED_REFS=\"\"",
+        # $1 = <registry>/<image>:<tag> just pushed; $2 = its digest (the index
+        # digest from 'skopeo copy --digestfile' on the multi-arch path). When
+        # $2 is empty (single-arch), resolve the lone manifest digest with
+        # 'skopeo inspect' — correct there since there is no list. oe-run-native
+        # prints 'Getting sysroot...' to stdout, so grep the digest out.
+        "sign_image() {",
+        "    prepare_skopeo",
+        "    prepare_cosign",
+        "    _SIG_DIGEST=\"$2\"",
+        "    if [ -z \"$_SIG_DIGEST\" ]; then _SIG_DIGEST=$(oe-run-native skopeo-native skopeo inspect --authfile %s --format '{{.Digest}}' docker://$1 | grep -oE 'sha256:[0-9a-f]{64}' | tail -n1); fi" % auth_config,
+        "    if [ -z \"$_SIG_DIGEST\" ]; then echo \"WARNING: could not resolve digest for $1, skipping cosign sign\"; return 0; fi",
+        "    _SIG_REF=\"${1%:*}@${_SIG_DIGEST}\"",
+        "    case \" $_SIGNED_REFS \" in *\" $_SIG_REF \"*) return 0 ;; esac",
+        "    _SIGNED_REFS=\"$_SIGNED_REFS $_SIG_REF\"",
+        # Sign by digest; --recursive also signs each child manifest. Treat a
+        # transparency-log conflict ('already exists' / HTTP 409, which also
+        # recurs on rebuilds that reproduce the same digest) as success so
+        # signing stays idempotent instead of aborting the step.
+        "    _sign_out=$(oe-run-native cosign-native cosign sign --recursive --key %s \"$_SIG_REF\" 2>&1) || {" % cosign_key,
+        "        case \"$_sign_out\" in",
+        "            *\"already exists\"*|*createLogEntryConflict*) echo \"cosign: $_SIG_REF already in transparency log, treating as signed\" ;;",
+        "            *) echo \"$_sign_out\" >&2; return 1 ;;",
+        "        esac",
+        "    }",
+        "    echo \"$_sign_out\"",
+        "}",
+    ]
+
 tag_cmds = utils.getconfiglist("CONTAINER_TAG_CMDS", ourconfig, args.target, args.stepnum)
 version_recipe = utils.getconfigvar("CONTAINER_VERSION_RECIPE", ourconfig, args.target, args.stepnum)
 for recipe, image in container_images.items():
@@ -205,12 +305,19 @@ for recipe, image in container_images.items():
     script.append("        echo \"WARNING: %s did not build (no OCI image at $_OCI_MULTIARCH_OUTPUT), skipping push\"" % recipe)
     script.append("    else")
     script.append("        prepare_skopeo")
+    # --digestfile records the index digest skopeo pushed, so signing can pin
+    # it directly (see sign_image); only needed when signing is enabled.
+    digestfile = ' --digestfile "$_DGSTFILE"' if cosign_key else ""
+    if cosign_key:
+        script.append("        _DGSTFILE=$(mktemp)")  # tiny tmp file, assumes autobuilder workdir is ephemeral
     for registry in registries:
         script += [
             "        for _tag in $_TAGS; do",
-            "            oe-run-native skopeo-native skopeo copy --all --dest-authfile %s oci:${_OCI_MULTIARCH_OUTPUT} docker://%s/%s:${_tag}" % (auth_config, registry, image),
-            "        done",
+            "            oe-run-native skopeo-native skopeo copy --all%s --dest-authfile %s oci:${_OCI_MULTIARCH_OUTPUT} docker://%s/%s:${_tag}" % (digestfile, auth_config, registry, image),
         ]
+        if cosign_key:
+            script.append("            sign_image %s/%s:${_tag} \"$(cat \"$_DGSTFILE\")\"" % (registry, image))
+        script.append("        done")
     script.append("    fi")
     script.append("else")
     # Single-arch: import the ${recipe}-latest-oci artefact into the memres VM
@@ -225,8 +332,10 @@ for recipe, image in container_images.items():
             "        for _tag in $_TAGS; do",
             "            %s-$(arch) vimport ${_DEPLOY_DIR_IMAGE}/%s-latest-oci %s/%s:${_tag}" % (runtime, recipe, registry, image),
             "            %s-$(arch) push %s/%s:${_tag}" % (runtime, registry, image),
-            "        done",
         ]
+        if cosign_key:
+            script.append("            sign_image %s/%s:${_tag}" % (registry, image))
+        script.append("        done")
     script.append("    fi")
     script.append("fi")
 # Tear-down is handled by the EXIT trap installed above.
