When you pull a container image from a registry, how do you know it actually came from a trusted source? How do you know no one tampered with it between the time it was built and the time it landed on your node?
This article walks through how image signing works, from the basics of supply chain security to hands-on cosign usage, and deep into the internals of key-based, keyless signing and GitHub Actions integration.
The Supply Chain Problem
When customers use your container image, they're placing a lot of trust in you. They're trusting that the software components included in the image are less vulnerable, that no one injected anything malicious, and that the image they're running is the exact artifact your pipeline built.
Supply chain security is the practice of giving customers confidence in the integrity and provenance of your software. Integrity means the artifact hasn't been modified. Provenance means you can prove where it came from and how it was built.
Two concepts we should be aware of:
SBOM (Software Bill of Materials):
A manifest of every software component that went into building the artifact: packages, versions, licenses. You can generate one using Trivy:
trivy image --format spdx-json kasisubbarayudu/buildkittest
This produces a JSON file that lists every package and its version in your image. If a CVE is dropped for a specific version of a software component, you can immediately know which of your images are affected.
Provenance:
It is the metadata describing how the artifact was built, when, and by which system. Together with an SBOM, provenance lets you trace an artifact back to its exact build pipeline run.
Generating this information is one thing. Making it tamper-proof and cryptographically verifiable is another. That's where signing comes in.
Why Sign Images?
Image signing directly addresses three supply chain risks:
Provenance: prove that an image was produced by your CI/CD system, not an attacker who got write access to your registry.
Integrity: detect if the image was modified after it was signed (any change to the image changes its digest, which would invalidate the signature)
Policy enforcement: Use tools like Kyverno/OPA Gatekeeper/Sigstore Policy Controller to block unsigned or unverified images from being deployed to your cluster at all.
In this article, we will use cosign to sign the images.
Key-Based Signing:
In case of key-based signing, we generate a key pair to sign the image and manage the key pair ourselves.
Generating a Keypair:
cosign generate-key-pair
This generates:
cosign.key: your private key, encrypted with a password you choose
cosign.pub: your public key, which you distribute freely to anyone who needs to verify your images
Signing an Image:
# Build and push your image first
docker build -t myregistry.io/myapp:v1.0 .
docker push myregistry.io/myapp:v1.0
# Sign it (will prompt for private key password)
cosign sign --key cosign.key myregistry.io/myapp:v1.0
One detail worth noting: cosign doesn't modify the image itself. The signature is stored as a separate artifact in the same registry, at a tag derived from the image digest:
myregistry.io/myapp:sha256-<digest>.sig
The original image is untouched, and the signature lives alongside it in the registry without requiring any special infrastructure.
Verifying a Signature:
cosign verify --key cosign.pub myregistry.io/myapp:v1.0
On success, cosign confirms the signature is valid and matches the public key. If the image has been tampered with or was never signed:
cosign verify --key cosign.pub myregistry.io/malicious:v1.0
# Error: no matching signatures
The Problem with Key-Based Signing:
Key-based signing works, but it comes with an operational burden: you're now running your own PKI. You need to manage the private key securely, rotate it, distribute the public key, handle revocation if the private key is compromised, and ensure the key isn't lost. For small teams, this can be manageable, but at scale, it becomes another piece of infrastructure to worry about.
This is the problem keyless signing was designed to solve.
Keyless Signing with Sigstore:
Keyless signing lets you sign artifacts without ever managing a long-lived private key. Instead of you holding the key, the signing key is ephemeral i.e it exists only for the duration of the signing operation and is then discarded. Identity is proven through OIDC.
The Sigstore ecosystem has three components that make this work:
Fulcio: a certificate authority that issues short-lived signing certificates
Rekor: an immutable, append-only transparency log that records all signatures
An OIDC provider: your identity source (Google, GitHub, etc.)
The Keyless Signing Flow (Step by Step):
For example when run cosign sign:
cosign sign kasisubbarayudu/buildkittest
Step 1: Ephemeral keypair generation
Cosign generates a keypair entirely in memory. The private key never touches disk. It exists only for this one signing operation.
Step 2: OIDC authentication
Cosign opens a browser and redirects you to the Sigstore OIDC broker (Dex), where you choose your identity provider, Google, GitHub, Microsoft, etc. You log in there.
cosign sign kasisubbarayudu/buildkittest
WARNING: Image reference kasisubbarayudu/buildkittest uses a tag, not a digest, to identify the image to sign.
This can lead you to sign a different image than the intended one. Please use a
digest (example.com/ubuntu@sha256:abc123...) rather than tag
(example.com/ubuntu:latest) for the input to cosign. The ability to refer to
images by tag will be removed in a future release.
Generating ephemeral keys...
The sigstore service, hosted by sigstore a Series of LF Projects, LLC, is provided pursuant to the Hosted Project Tools Terms of Use, available at https://lfprojects.org/policies/hosted-project-tools-terms-of-use/.
Note that if your submission includes personal data associated with this signed artifact, it will be part of an immutable record.
This may include the email address associated with the account with which you authenticate your contractual Agreement.
This information will be used for signing this artifact and will be stored in public transparency logs and cannot be removed later, and is subject to the Immutable Record notice at https://lfprojects.org/policies/hosted-project-tools-immutable-records/.
By typing 'y', you attest that (1) you are not submitting the personal data of any other person; and (2) you understand and agree to the statement and the Agreement terms at the URLs listed above.
Are you sure you would like to continue? [y/N] y
Your browser will now be opened to:
https://oauth2.sigstore.dev/auth/auth?access_type=online&client_id=sigstore&code_challenge=plxge1Pb05zqWvd8VWJrQjKUlWK1ZrKm0hCk9BT1iZE&code_challenge_method=S256&nonce=TtZVuGy75XjszlKtZgAw7Q&redirect_uri=http%3A%2F%2Flocalhost%3A39883%2Fauth%2Fcallback&response_type=code&scope=openid+email&state=C4INaDJe_sTR7DOpJLWr6g
Opening in existing browser session.
Ephemeral keys are generated and kept in memory. As shown in the URL above, the link opens in the browser and connects to the auth endpoint of the DEX intermediate OIDC server. The response type is code, which means it uses the authorization code grant type with PKCE. Since Cosign is not a server-side application and runs on the user's device, it is not secure to store or embed the client ID or client secret in the Cosign binary. That is why the authorization code grant type with PKCE is used. In OIDC terms, cosign is considered a public client. OpenID scopes are specified to get more information about the user. Because these scopes are included, an ID token is returned along with the access token.
Here's something interesting: if you watch the browser URL carefully when you click "Sign in with Google," you'll notice the request goes to accounts.google.com, but the client_id belongs to Sigstore, and the redirect_uri points back to oauth2.sigstore.dev, not to your machine or cosign.
https://accounts.google.com/v3/signin/accountchooser?client_id=237800849078-hri2ndt7gdafpf34kq8crd5sik9pe3so.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Foauth2.sigstore.dev%2Fauth%2Fcallback&response_type=code&scope=openid+email&state=g57y3w6qy5ryk3u3q6bqekksp&dsh=S17075643%3A1774614974239725&o2v=2&service=lso&flowName=GeneralOAuthFlow&opparams=%253F&continue=https%3A%2F%2Faccounts.google.com%2Fsignin%2Foauth%2Fconsent%3Fauthuser%3Dunknown%26part%3DAJi8hANxu7AUaB6qU0RLCkwfiTl4NHKUzRTWxKZCL42fnEeigK80dX0JK9s3Uyi77N_lbNkMGEOjXsPmcD4C-3KKFFO1tgfnxveQAeqkz16cf8SWqwIrmMpMNBQieuwTqCWPgZf8SvkXEQ1c1mLhdHoarm438kgSAQINgb-GfWuvpu0DJKs9UoYyylZ2h0_h1Mk5mCZWmbEhm7Y8tAQjXe4pWfJiPZdsgld2Fuc-jADHCJnaypVIhVP4gCdSzlaOfdpl6oalYkJW5jtApJSExnjDIuuouSgksjbdw0_npecXC7hvHX5FDDjB6TCvcFo2q6SE8k8Lr2Ucj7zRWKmql3k7wdQvCZuUzaTLcM4-DucAvqDFuvbwhXwnCQzD2uSAwHb_VmRX5doPPW-aDY2juK0Y6P4lXfbwwttGaFupmCSvQIN8VjJuYdC43fTttfDD8M13CVZIddKqRicSrU5wPSpMYpRYXUysqg%26flowName%3DGeneralOAuthFlow%26as%3DS17075643%253A1774614974239725%26client_id%3D237800849078-hri2ndt7gdafpf34kq8crd5sik9pe3so.apps.googleusercontent.com%26requestPath%3D%252Fsignin%252Foauth%252Fconsent%23&app_domain=https%3A%2F%2Foauth2.sigstore.dev
client_id=237800849078-hri2ndt7gdafpf34kq8crd5sik9pe3so.apps.googleusercontent.com, This is Sigstore's own Google OAuth app ID. Sigstore registered itself as an app with Google. So Google sees Sigstore as the client, not you, not cosign.
After successful login:
Reason:
Many popular identity providers don't allow arbitrary values in the aud claim. For instance, Google OIDC populates the value from a registered OAuth 2.0 application's client ID and doesn't allow user-customizable values at all. Facebook and Microsoft do the same.
This is the core reason Dex exists. Fulcio needs to receive a token with aud=sigstore so it knows the token was intended for it. But Google will never let you set that audience claim, it's locked to Sigstore's own registered client ID. So Dex acts as the intermediary: your Google token goes to Dex (as Sigstore's registered Google OAuth client), Dex re-issues a new token with aud=sigstore, and that's what gets sent to Fulcio.
Once you authenticate, an OIDC id_token is produced and flows through this chain:
Google → Sigstore OAuth proxy → Cosign
Cosign also runs a small local HTTP server on localhost to receive the OAuth callback (the same pattern you'd see if you've ever implemented OIDC yourself). It uses the authorization code + PKCE flow.
Step 3: Certificate issuance from Fulcio
Cosign sends three things to Fulcio:
- The OIDC id_token
- The ephemeral public key
- A signed challenge, a signature of the sub claim of the OIDC token, proving the ownership of the private key
Fulcio validates the OIDC token (checking it was actually signed by the OIDC provider), then proves that Cosign holds the private key by verifying the signature of the sub claim. Once both checks pass, Fulcio issues a short-lived X.509 certificate using its own CA, and this certificate is typically valid for 10 minutes, which binds your identity (the email from the OIDC token) to the ephemeral public key.
Step 4: Signing and uploading
Cosign takes the image digest (the sha256 hash), signs it with the ephemeral private key, and then uploads the signature, the certificate, and the transparency log entry to Rekor. The signature is also uploaded to the image registry (.sig artifact). After this, the private key is discarded.
Step 5: Verification
When anyone runs cosign verify:
- Cosign fetches the .sig artifact from the registry.
Cosign fetches the list of artifacts, such as SBOM, signature, and any other attestations attached to the image. Behind the scenes, it uses the referrers api of Docker. Among the returned artifacts, it tries to find the one that matches the
artifactType: application/vnd.dev.sigstore.bundle.v0.3+json.
Ex: for image dockerhub.io/kasisubbarayudu/buildkittest,
curl -s "https://registry-1.docker.io/v2/kasisubbarayudu/buildkittest/referrers/sha256:20436a2b1237a073a1d8035342065b767923e7b884a41b049da8ca57f0ce7df2" \
-H "Accept: application/vnd.oci.image.manifest.v1+json" \
-H "Authorization: Bearer $(curl -s 'https://auth.docker.io/token?service=registry.docker.io&scope=repository:kasisubbarayudu/buildkittest:pull' | jq -r .token)" \
| jq .
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"size": 889,
"digest": "sha256:07dc49a44fb2ef30e67e2f50cf5485ae12b1b62ddb91cfc1fb24d9f604bd7689",
"annotations": {
"dev.sigstore.bundle.content": "dsse-envelope",
"dev.sigstore.bundle.predicateType": "https://sigstore.dev/cosign/sign/v1",
"org.opencontainers.image.created": "2026-06-08T07:05:26Z"
},
"artifactType": "application/vnd.dev.sigstore.bundle.v0.3+json"
}
]
}
In the manifests we see one entry as other attestation for ex SBOM is not attached and only signature is attached.
You can use docker's manifest API, to get detailed info about the manifest:
curl -s "https://registry-1.docker.io/v2/kasisubbarayudu/buildkittest/manifests/sha256:07dc49a44fb2ef30e67e2f50cf5485ae12b1b62ddb91cfc1fb24d9f604bd7689" \
-H "Accept: application/vnd.oci.image.manifest.v1+json" \
-H "Authorization: Bearer $(curl -s 'https://auth.docker.io/token?service=registry.docker.io&scope=repository:kasisubbarayudu/buildkittest:pull' | jq -r .token)" \
| jq .
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.empty.v1+json",
"size": 2,
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"artifactType": "application/vnd.dev.sigstore.bundle.v0.3+json"
},
"layers": [
{
"mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json",
"size": 6867,
"digest": "sha256:4e5a76ee7871b1349693013c23483305239cacd9e2e9b838aa6988a39ce65e3c"
}
],
"annotations": {
"dev.sigstore.bundle.content": "dsse-envelope",
"dev.sigstore.bundle.predicateType": "https://sigstore.dev/cosign/sign/v1",
"org.opencontainers.image.created": "2026-06-08T07:05:26Z"
},
"subject": {
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"size": 2406,
"digest": "sha256:20436a2b1237a073a1d8035342065b767923e7b884a41b049da8ca57f0ce7df2"
},
"artifactType": "application/vnd.dev.sigstore.bundle.v0.3+json"
}
And in the output, layers list contains one layer, whose sha value is "sha256:4e5a76ee7871b1349693013c23483305239cacd9e2e9b838aa6988a39ce65e3c" and is of type application/vnd.dev.sigstore.bundle.v0.3+json.
And if u try to get this blob using blob api:
curl \
-H "Authorization: Bearer $(curl -s 'https://auth.docker.io/token?service=registry.docker.io&scope=repository:kasisubbarayudu/buildkittest:pull' | jq -r .token)" \
"https://registry-1.docker.io/v2/kasisubbarayudu/buildkittest/blobs/sha256:4e5a76ee7871b1349693013c23483305239cacd9e2e9b838aa6988a39ce65e3c" -L | jq
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
100 6867 100 6867 0 0 6119 0 0:00:01 0:00:01 --:--:-- 6119
{
"mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json",
"verificationMaterial": {
"certificate": {
"rawBytes": "MIIDMDCCArWgAwIBAgIUDQfrQyDE+a0p1AGOeILRzKPdoFswCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYwNjA4MDcwNTI1WhcNMjYwNjA4MDcxNTI1WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwmo8XT1QN0I66R6shMuxGVfiS4/DKjrUB38+5OxC1fE1r/VCa2dt/hvDXO+MhmaKjooAw40Fuk5ePyvlzbeuWqOCAdQwggHQMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUN3GvpnBI8TR3+HPngQojmxxAkP4wHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wJwYDVR0RAQH/BB0wG4EZa2FzaXJhbWtvbXBlbGxhQGdtYWlsLmNvbTApBgorBgEEAYO/MAEBBBtodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20wKwYKKwYBBAGDvzABCAQdDBtodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20wWwYKKwYBBAGDvzABGARNDEtDaFV4TURBMk16WTBPVGc1TWpReE1qQXpOVE0wT0RjU0gyaDBkSEJ6T2lVeVJpVXlSbUZqWTI5MWJuUnpMbWR2YjJkc1pTNWpiMjAwgYoGCisGAQQB1nkCBAIEfAR6AHgAdgDdPTBqxscRMmMZHhyZZzcCokpeuN48rf+HinKALynujgAAAZ6mDOyqAAAEAwBHMEUCIQCpgLPskeYhfJtvkTZYKEDm+Wvr1wbE6r0GYxA7YAe1igIgNWMh/QYATTjbIUB35BJivXA3ptzlwZAOR9si3uhkRqswCgYIKoZIzj0EAwMDaQAwZgIxALMQKyyQXlXc1cwBbhgakQwoBgbIF0eftEEmIkzclOotX8j6v79D0jkbPfmVDAr+MAIxALya8kp3WQarAgotAasne32EQcBm8MFEI4ClIyMoyaKUkzFYqkUYfBwX6IkdXNS/HA=="
},
"tlogEntries": [
{
"logIndex": "1754783759",
"logId": {
"keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="
},
"kindVersion": {
"kind": "dsse",
"version": "0.0.1"
},
"integratedTime": "1780902326",
"inclusionPromise": {
"signedEntryTimestamp": "MEQCIEeby7uJfKo1ZqR/Z8clj8942TRqn9FeZtqWnoLZOBMtAiBYJBPc1Hkffz12y5d9l0sLQbu0ZZQ2NOB38I6gMAsWFQ=="
},
"inclusionProof": {
"logIndex": "1632879497",
"rootHash": "hQVgleW4096bWB5nMVA4euGOFNXspkZM7fkwOU0DRTo=",
"treeSize": "1632879500",
"hashes": [
"XHIMw0aYbwSRZIDloHHhezy6eFMxDuBywnaCAEZ/hCE=",
"47B8Brg1p3ZJ5Q20oIZDiAdFEs/Eg1bJ4T9EkyqGnwU=",
"PCTg9y1uHHEyzk0yrF+0KIvYlf739uZWx8mLPkD4qWM=",
"IG3DXh2vVc0tB/VXEUb49zN6VKXFgDbkYFjSbEoLnis=",
"/6gQW6oyjPBJt9hHaloGnKe/cqwTgDP+ehCA17xJDnE=",
"4OW3KC98wj7ppZzIz78pbAZfUPaNQOFcX/+X1RWAqEk=",
"rrZJWna+MzeY9FZ/DOBhYRvAmyLC+usz7+CSc7D6ABA=",
"EqM3uHc9tMy5EM7eybA0+bnEzNFb6bYf02wL+9dv7qc=",
"AKwEP2HH5SGxOZY6n7sb0sQ8/KMJRFtSn3nT/co5fZY=",
"RjW1XPhqoinfsobGd20S3gJis0k6WUSEpjaQ6JXQ5FI=",
"BhdMVwa7xxwCWC//5qOnv/wqs69cjw8nHrPed7oJkAI=",
"9jo43+euK7XyKWxBn2h5BwzuDA8PaUq2geJ8WGWycpQ=",
"x5jI42lOBdSMG7JZOSaglWmLNIaPCZTz6pXBlb7ZgYQ=",
"daxmZaajRpZV+JxHiOYZhJBiSKN5ucqjh2WnGbHhirw=",
"DOCeoSMovIvLExkhIvisow9AuNXgeWs4ECkyR6EcqYU="
],
"checkpoint": {
"envelope": "rekor.sigstore.dev - 1193050959916656506\n1632879500\nhQVgleW4096bWB5nMVA4euGOFNXspkZM7fkwOU0DRTo=\n\n— rekor.sigstore.dev wNI9ajBGAiEApRMPDWfsFq4rhLkDJBbREL2Wc5ZAqxWaGMylgm23WfUCIQDsyP5tohOidsES6Hb9KWdS0xzaoPzuMIPslnSS010XhA==\n"
}
},
"canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiOTE5ZjYyNzM3OWZiYWM3NjUxODc5ZWRiZTdhN2NhMDM2OTVjODA4YjFkODk2MThmNjhhNjNlM2VmNDY5OTAyZCJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6ImU1NDI4OTc5NGFkZGQyYWU5NDVkODc2NDY0MzRiMTMzZjE1MjU0ODFiMDUxMTAzZmIyN2YxNDQxYWU3NjY0YzkifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVVQ0lDTmFzY3N1MTNtcGRjTG5ET21KMkNEc25TYW9QbHFSR3RGOFlyMjBsVkZwQWlFQXZ1L3RCVFArWTJuOFlyYXRnUFoveE5ZSkc2RWpNaGoxckQ3VU9pdlNVRjg9IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VSTlJFTkRRWEpYWjBGM1NVSkJaMGxWUkZGbWNsRjVSRVVyWVRCd01VRkhUMlZKVEZKNlMxQmtiMFp6ZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwWmQwNXFRVFJOUkdOM1RsUkpNVmRvWTA1TmFsbDNUbXBCTkUxRVkzaE9WRWt4VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVjNiVzg0V0ZReFVVNHdTVFkyVWpaemFFMTFlRWRXWm1sVE5DOUVTMnB5VlVJek9Dc0tOVTk0UXpGbVJURnlMMVpEWVRKa2RDOW9ka1JZVHl0TmFHMWhTMnB2YjBGM05EQkdkV3MxWlZCNWRteDZZbVYxVjNGUFEwRmtVWGRuWjBoUlRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVk9NMGQyQ25CdVFrazRWRkl6SzBoUWJtZFJiMnB0ZUhoQmExQTBkMGgzV1VSV1VqQnFRa0puZDBadlFWVXpPVkJ3ZWpGWmEwVmFZalZ4VG1wd1MwWlhhWGhwTkZrS1drUTRkMHAzV1VSV1VqQlNRVkZJTDBKQ01IZEhORVZhWVRKR2VtRllTbWhpVjNSMllsaENiR0pIZUdoUlIyUjBXVmRzYzB4dFRuWmlWRUZ3UW1kdmNncENaMFZGUVZsUEwwMUJSVUpDUW5SdlpFaFNkMk42YjNaTU1rWnFXVEk1TVdKdVVucE1iV1IyWWpKa2MxcFROV3BpTWpCM1MzZFpTMHQzV1VKQ1FVZEVDblo2UVVKRFFWRmtSRUowYjJSSVVuZGplbTkyVERKR2Fsa3lPVEZpYmxKNlRHMWtkbUl5WkhOYVV6VnFZakl3ZDFkM1dVdExkMWxDUWtGSFJIWjZRVUlLUjBGU1RrUkZkRVJoUmxZMFZGVlNRazFyTVRaWFZFSlFWa2RqTVZSWGNGSmxSVEZ4VVZod1QxWkZNSGRVTUZKcVZUQm5lV0ZFUW10VFJVbzJWREpzVmdwbFZrcHdWbGhzVTJKVlduRlhWRWsxVFZkS2RWVnVjRTFpVjFJeVdXcEthMk14Y0ZST1YzQnBUV3BCZDJkWmIwZERhWE5IUVZGUlFqRnVhME5DUVVsRkNtWkJValpCU0dkQlpHZEVaRkJVUW5GNGMyTlNUVzFOV2tob2VWcGFlbU5EYjJ0d1pYVk9ORGh5Wml0SWFXNUxRVXg1Ym5WcVowRkJRVm8yYlVSUGVYRUtRVUZCUlVGM1FraE5SVlZEU1ZGRGNHZE1VSE5yWlZsb1prcDBkbXRVV2xsTFJVUnRLMWQyY2pGM1lrVTJjakJIV1hoQk4xbEJaVEZwWjBsblRsZE5hQW92VVZsQlZGUnFZa2xWUWpNMVFrcHBkbGhCTTNCMGVteDNXa0ZQVWpsemFUTjFhR3RTY1hOM1EyZFpTVXR2V2tsNmFqQkZRWGROUkdGUlFYZGFaMGw0Q2tGTVRWRkxlWGxSV0d4WVl6RmpkMEppYUdkaGExRjNiMEpuWWtsR01HVm1kRVZGYlVscmVtTnNUMjkwV0RocU5uWTNPVVF3YW10aVVHWnRWa1JCY2lzS1RVRkplRUZNZVdFNGEzQXpWMUZoY2tGbmIzUkJZWE51WlRNeVJWRmpRbTA0VFVaRlNUUkRiRWw1VFc5NVlVdFZhM3BHV1hGclZWbG1RbmRZTmtsclpBcFlUbE12U0VFOVBRb3RMUzB0TFVWT1JDQkRSVkpVU1VaSlEwRlVSUzB0TFMwdENnPT0ifV19fQ=="
}
],
"timestampVerificationData": {
"rfc3161Timestamps": [
{
"signedTimestamp": "MIICyTADAgEAMIICwAYJKoZIhvcNAQcCoIICsTCCAq0CAQMxDTALBglghkgBZQMEAgEwgbgGCyqGSIb3DQEJEAEEoIGoBIGlMIGiAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQgu2VISVNbYk+VQZDi6yCnss5LGGVpFuknPHtFsktL4OECFQD6OOcltBBcOCkve+XSgeOBgmWURBgPMjAyNjA2MDgwNzA1MjVaMAMCAQGgMqQwMC4xFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEVMBMGA1UEAxMMc2lnc3RvcmUtdHNhoAAxggHaMIIB1gIBATBRMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQCFDoTVC8MkGHuvMFDL8uKjosqI4sMMAsGCWCGSAFlAwQCAaCB/DAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQwHAYJKoZIhvcNAQkFMQ8XDTI2MDYwODA3MDUyNVowLwYJKoZIhvcNAQkEMSIEIAZYYA1ib9CRgqXzK1RqCxC+FINsWTmm2Bd5EOH9v7n6MIGOBgsqhkiG9w0BCRACLzF/MH0wezB5BCCF+Se8B6tiysO0Q1bBDvyBssaIP9p6uebYcNnROs0FtzBVMD2kOzA5MRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxIDAeBgNVBAMTF3NpZ3N0b3JlLXRzYS1zZWxmc2lnbmVkAhQ6E1QvDJBh7rzBQy/Lio6LKiOLDDAKBggqhkjOPQQDAgRmMGQCMBspPBil7OP6OoSS1pPrreov6kqG/blObbEfqvdkcqwlet9pKNcGkrm/1AdgPu0fsAIwfUfRv0eF497BMcGmeO1SEmBxXMWj9VJQt0kp+IYAB+8wSD0n3o8wmY+sQ2FFczSL"
}
]
}
},
"dsseEnvelope": {
"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCAic3ViamVjdCI6W3siZGlnZXN0Ijp7InNoYTI1NiI6IjIwNDM2YTJiMTIzN2EwNzNhMWQ4MDM1MzQyMDY1Yjc2NzkyM2U3Yjg4NGE0MWIwNDlkYThjYTU3ZjBjZTdkZjIifSwgImFubm90YXRpb25zIjp7fX1dLCAicHJlZGljYXRlVHlwZSI6Imh0dHBzOi8vc2lnc3RvcmUuZGV2L2Nvc2lnbi9zaWduL3YxIiwgInByZWRpY2F0ZSI6e319",
"payloadType": "application/vnd.in-toto+json",
"signatures": [
{
"sig": "MEUCICNascsu13mpdcLnDOmJ2CDsnSaoPlqRGtF8Yr20lVFpAiEAvu/tBTP+Y2n8YratgPZ/xNYJG6EjMhj1rD7UOivSUF8="
}
]
}
}
It returns the JSON response since the layer mediaType was application/vnd.dev.sigstore.bundle.v0.3+json json.
When cosign verify is run, for ex:
cosign verify kasisubbarayudu/buildkittest --certificate-oidc-issuer https://accounts.google.com --certificate-identity kasisubbarayudu@gmail.com
cosign does something similar: it fetches the JSON bundle from the signature artifact, then decodes the RAW certificate bytes, then extracts the Subject Alternative Name, checks whether it matches the --certificate-identity.
Decoded cert:
echo "MIIDMDCCArWgAwIBAgIUDQfrQyDE+a0p1AGOeILRzKPdoFswCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYwNjA4MDcwNTI1WhcNMjYwNjA4MDcxNTI1WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwmo8XT1QN0I66R6shMuxGVfiS4/DKjrUB38+5OxC1fE1r/VCa2dt/hvDXO+MhmaKjooAw40Fuk5ePyvlzbeuWqOCAdQwggHQMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUN3GvpnBI8TR3+HPngQojmxxAkP4wHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wJwYDVR0RAQH/BB0wG4EZa2FzaXJhbWtvbXBlbGxhQGdtYWlsLmNvbTApBgorBgEEAYO/MAEBBBtodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20wKwYKKwYBBAGDvzABCAQdDBtodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20wWwYKKwYBBAGDvzABGARNDEtDaFV4TURBMk16WTBPVGc1TWpReE1qQXpOVE0wT0RjU0gyaDBkSEJ6T2lVeVJpVXlSbUZqWTI5MWJuUnpMbWR2YjJkc1pTNWpiMjAwgYoGCisGAQQB1nkCBAIEfAR6AHgAdgDdPTBqxscRMmMZHhyZZzcCokpeuN48rf+HinKALynujgAAAZ6mDOyqAAAEAwBHMEUCIQCpgLPskeYhfJtvkTZYKEDm+Wvr1wbE6r0GYxA7YAe1igIgNWMh/QYATTjbIUB35BJivXA3ptzlwZAOR9si3uhkRqswCgYIKoZIzj0EAwMDaQAwZgIxALMQKyyQXlXc1cwBbhgakQwoBgbIF0eftEEmIkzclOotX8j6v79D0jkbPfmVDAr+MAIxALya8kp3WQarAgotAasne32EQcBm8MFEI4ClIyMoyaKUkzFYqkUYfBwX6IkdXNS/HA==" | base64 -d | openssl x509 -inform DER -text -noout
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
0d:07:eb:43:20:c4:f9:ad:29:d4:01:8e:78:82:d1:cc:a3:dd:a0:5b
Signature Algorithm: ecdsa-with-SHA384
Issuer: O = sigstore.dev, CN = sigstore-intermediate
Validity
Not Before: Jun 8 07:05:25 2026 GMT
Not After : Jun 8 07:15:25 2026 GMT
Subject:
Subject Public Key Info:
Public Key Algorithm: id-ecPublicKey
Public-Key: (256 bit)
pub:
04:c2:6a:3c:5d:3d:50:37:42:3a:e9:1e:ac:84:cb:
b1:19:57:e2:4b:8f:c3:2a:3a:d4:07:7f:3e:e4:ec:
42:d5:f1:35:af:f5:42:6b:67:6d:fe:1b:c3:5c:ef:
8c:86:66:8a:8e:8a:00:c3:8d:05:ba:4e:5e:3f:2b:
e5:cd:b7:ae:5a
ASN1 OID: prime256v1
NIST CURVE: P-256
X509v3 extensions:
X509v3 Key Usage: critical
Digital Signature
X509v3 Extended Key Usage:
Code Signing
X509v3 Subject Key Identifier:
37:71:AF:A6:70:48:F1:34:77:F8:73:E7:81:0A:23:9B:1C:40:90:FE
X509v3 Authority Key Identifier:
DF:D3:E9:CF:56:24:11:96:F9:A8:D8:E9:28:55:A2:C6:2E:18:64:3F
X509v3 Subject Alternative Name: critical
email:kasisubbarayudu@gmail.com
1.3.6.1.4.1.57264.1.1:
https://accounts.google.com
1.3.6.1.4.1.57264.1.8:
..https://accounts.google.com
1.3.6.1.4.1.57264.1.24:
.KChUxMDA2MzY0OTg5MjQxMjAzNTM0ODcSH2h0dHBzOiUyRiUyRmFjY291bnRzLmdvb2dsZS5jb20
CT Precertificate SCTs:
Signed Certificate Timestamp:
Version : v1 (0x0)
Log ID : DD:3D:30:6A:C6:C7:11:32:63:19:1E:1C:99:67:37:02:
A2:4A:5E:B8:DE:3C:AD:FF:87:8A:72:80:2F:29:EE:8E
Timestamp : Jun 8 07:05:25.418 2026 GMT
Extensions: none
Signature : ecdsa-with-SHA256
30:45:02:21:00:A9:80:B3:EC:91:E6:21:7C:9B:6F:91:
36:58:28:40:E6:F9:6B:EB:D7:06:C4:EA:BD:06:63:10:
3B:60:07:B5:8A:02:20:35:63:21:FD:06:00:4D:38:DB:
21:40:77:E4:12:62:BD:70:37:A6:DC:E5:C1:90:0E:47:
DB:22:DE:E8:64:46:AB
Signature Algorithm: ecdsa-with-SHA384
Signature Value:
30:66:02:31:00:b3:10:2b:2c:90:5e:55:dc:d5:cc:01:6e:18:
1a:91:0c:28:06:06:c8:17:47:9f:b4:41:26:22:4c:dc:94:ea:
2d:5f:c8:fa:bf:bf:43:d2:39:1b:3d:f9:95:0c:0a:fe:30:02:
31:00:bc:9a:f2:4a:77:59:06:ab:02:0a:2d:01:ab:27:7b:7d:
84:41:c0:66:f0:c1:44:23:80:a5:23:23:28:c9:a2:94:93:31:
58:aa:45:18:7c:1c:17:e8:89:1d:5c:d4:bf:1c
Also checks if issuer matches --certificate-oidc-issuer.
It also verifies if the certificate is really signed by the Fulcio CA.
Most importantly, it validates the signature of the image. It decodes the dsseEnvelope.payload from the JSON response. This payload contains the image digest that was signed.
In our case, for ex:
echo "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCAic3ViamVjdCI6W3siZGlnZXN0Ijp7InNoYTI1NiI6IjIwNDM2YTJiMTIzN2EwNzNhMWQ4MDM1MzQyMDY1Yjc2NzkyM2U3Yjg4NGE0MWIwNDlkYThjYTU3ZjBjZTdkZjIifSwgImFubm90YXRpb25zIjp7fX1dLCAicHJlZGljYXRlVHlwZSI6Imh0dHBzOi8vc2lnc3RvcmUuZGV2L2Nvc2lnbi9zaWduL3YxIiwgInByZWRpY2F0ZSI6e319" | base64 -d
{"_type":"https://in-toto.io/Statement/v1", "subject":[{"digest":{"sha256":"20436a2b1237a073a1d8035342065b767923e7b884a41b049da8ca57f0ce7df2"}, "annotations":{}}], "predicateType":"https://sigstore.dev/cosign/sign/v1", "predicate":{}}
And then it validates the signature dsseEnvelope.signatures.sig using public key from certificate.
Here, the certificate is valid for 10 minutes only. The Rekor log entry is the durable record, not the certificate's validity period.
Keyless Signing in GitHub Actions
The flow above works for human-initiated signing from a laptop. But in CI/CD pipelines, there's no browser, no human to click through an OAuth flow. This is where GitHub Actions' OIDC integration comes in.
How It Works?
GitHub Actions can generate OIDC tokens for workflow runs. These tokens identify the workflow itself, the repository, the ref, the workflow file path, rather than a human email address. To enable this, your workflow needs:
permissions:
contents: read
id-token: write
The id-token: write permission tells GitHub to inject two environment variables into the runner:
ACTIONS_ID_TOKEN_REQUEST_URL: the GitHub OIDC endpoint
ACTIONS_ID_TOKEN_REQUEST_TOKEN: a bearer token to authenticate with that endpoint (not the final OIDC token itself — this is a common point of confusion)
When cosign runs in the pipeline, it internally does something like this:
GET $ACTIONS_ID_TOKEN_REQUEST_URL?audience=sigstore
Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN
GitHub responds with an actual OIDC id_token whose aud claim is set to sigstore. From there, the flow is identical to the keyless human flow: cosign sends this token plus the ephemeral public key to Fulcio, gets a certificate, signs the image digest, and uploads to Rekor.
Again, since Google doesn't support setting custom aud claims, a Dex intermediate OIDC server was needed, but in the case of GitHub actions, since it allows token creation with a custom audience, a Dex server isn't needed.
Sample workflow:
name: Build, Sign, Push to DockerHub
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
jobs:
build-sign-push:
runs-on: ubuntu-latest
steps:
- name: Checkout Source Code
uses: actions/checkout@v4
- name: Install Cosign
uses: sigstore/cosign-installer@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Docker Image
id: build-push
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/myapp:${{ github.sha }}
- name: Sign Image (Keyless)
run: |
cosign sign --yes \
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/myapp@${{ steps.build-push.outputs.digest }}
- name: Verify Signature
run: |
cosign verify \
--certificate-identity "https://github.com/${{ github.repository }}/.github/workflows/workflow.yaml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/myapp@${{ steps.build-push.outputs.digest }}
One thing to note here:
The certificate identity is the workflow path. When verifying an Actions-signed image, the --certificate-identity isn't an email; it's the fully-qualified workflow file URL. The Fulcio certificate for a GitHub Actions signing looks like:
data:
Serial Number: '0x05f43452ef80bc88abeda2b2495909fa1236ce61'
Signature:
Issuer: O=sigstore.dev, CN=sigstore-intermediate
Validity:
Not Before: 15 days ago (2026-04-02T20:06:17+05:30)
Not After: 15 days ago (2026-04-02T20:16:17+05:30)
Algorithm:
name: ECDSA
namedCurve: P-256
Subject:
extraNames:
items: {}
asn: []
X509v3 extensions:
Key Usage (critical):
- Digital Signature
Extended Key Usage:
- Code Signing
Subject Key Identifier:
- C0:B2:07:6D:68:73:B1:57:3D:DC:E1:A5:7B:4A:ED:E1:9B:8E:6C:21
Authority Key Identifier:
keyid: DF:D3:E9:CF:56:24:11:96:F9:A8:D8:E9:28:55:A2:C6:2E:18:64:3F
Subject Alternative Name (critical):
url:
- https://github.com/srisaikompella/pr-test/.github/workflows/workflow.yaml@refs/heads/main
OIDC Issuer: https://token.actions.githubusercontent.com
GitHub Workflow Trigger: push
GitHub Workflow SHA: fe04ec0ce732b6be9fc1446333eceb111bfb0daf
GitHub Workflow Name: Build, Sign, Push to DockerHub
GitHub Workflow Repository: srisaikompella/pr-test
GitHub Workflow Ref: refs/heads/main
OIDC Issuer (v2): https://token.actions.githubusercontent.com
Build Signer URI: https://github.com/srisaikompella/pr-test/.github/workflows/workflow.yaml@refs/heads/main
Build Signer Digest: fe04ec0ce732b6be9fc1446333eceb111bfb0daf
Runner Environment: github-hosted
Source Repository URI: https://github.com/srisaikompella/pr-test
Source Repository Digest: fe04ec0ce732b6be9fc1446333eceb111bfb0daf
Source Repository Ref: refs/heads/main
Source Repository Identifier: '605870062'
Source Repository Owner URI: https://github.com/srisaikompella
Source Repository Owner Identifier: '69502069'
Build Config URI: https://github.com/srisaikompella/pr-test/.github/workflows/workflow.yaml@refs/heads/main
Build Config Digest: fe04ec0ce732b6be9fc1446333eceb111bfb0daf
Build Trigger: push
Run Invocation URI: https://github.com/srisaikompella/pr-test/actions/runs/23905840052/attempts/1
Source Repository Visibility At Signing: public
1.3.6.1.4.1.11129.2.4.2: 04:7a:00:78:00:76:00:dd:3d:30:6a:c6:c7:11:32:63:19:1e:1c:99:67:37:02:a2:4a:5e:b8:de:3c:ad:ff:87:8a:72:80:2f:29:ee:8e:00:00:01:9d:4e:9f:a0:9d:00:00:04:03:00:47:30:45:02:20:14:74:26:99:7a:9a:07:ba:a0:e9:a6:b2:cc:bf:7a:8b:53:6b:4b:d9:c8:d1:74:ec:61:13:df:b8:9b:46:e1:5d:02:21:00:96:4e:cd:77:2d:e4:00:6c:1c:cf:a1:bd:72:d3:4a:7b:32:8f:29:94:da:f6:1b:48:f9:6e:d0:5e:5e:78:be:85
Final Thoughts
Signing images is only half the story. The other half is making sure your cluster actually enforces it, rejecting any workload whose image wasn't signed. In the next article, we will setup sigstore policy controller that blocks unsigned images.


Top comments (0)