DevOps ·

Securing Your CI/CD Pipeline: OIDC Authentication, Artifact Signing, and Runtime Attestation for Production Deployments

Long-lived credentials in CI are an accident waiting to happen. This guide covers replacing static secrets with OIDC federation, signing build artifacts with Sigstore/cosign, generating SLSA provenance, and enforcing signed images at runtime with Kubernetes admission controllers.

Securing Your CI/CD Pipeline: OIDC Authentication, Artifact Signing, and Runtime Attestation for Production Deployments

Most CI/CD pipelines are secured the same way: a bucket of long-lived secrets stored in environment variables, rotated manually when someone leaves the team or when a security audit raises a flag. That model works right up until it does not. A leaked AWS_SECRET_ACCESS_KEY in a log file, a compromised GitHub account, a malicious dependency that exfiltrates environment variables at build time - the blast radius is unbounded because the credential is valid anywhere, any time.

The shift from secret-based auth to identity-based auth is not new, but in CI/CD it has only become practical in the last few years. OIDC federation, Sigstore’s keyless signing infrastructure, and SLSA provenance collectively give you a pipeline where every step can be verified, not just trusted.

This article covers the full chain: replacing long-lived credentials with short-lived OIDC tokens, signing build artifacts so their provenance is verifiable, generating SLSA attestations, and enforcing all of that at deploy time with Kubernetes admission controllers.

Why Long-Lived Credentials Are the Wrong Model

When you store AWS_SECRET_ACCESS_KEY as a GitHub Actions secret, you are creating a credential that:

  • Has no intrinsic binding to a specific workflow or repository
  • Persists until manually revoked
  • Can be exfiltrated by any step in any job that has access to it
  • Has no audit trail that ties it to a specific pipeline run

The 2023 CircleCI incident, where attackers exfiltrated customer environment variables by compromising an engineer’s machine, illustrated this perfectly. The credentials were valid, so there was nothing to stop their use.

OIDC federation inverts this model. Instead of a pre-shared secret, your CI provider (GitHub Actions, in this article) acts as an identity provider. Each workflow run gets a short-lived JWT signed by GitHub. AWS, GCP, or any OIDC-aware service can verify that JWT directly and issue temporary credentials scoped to that specific job.

Replacing Static AWS Credentials with OIDC

On the AWS side, you create an IAM OIDC identity provider that trusts GitHub’s token endpoint, then create a role that can be assumed by tokens matching specific claims.

// infrastructure/iam-oidc.ts (CDK example)
import * as iam from "aws-cdk-lib/aws-iam";
import * as cdk from "aws-cdk-lib";

export class GitHubOidcStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string) {
    super(scope, id);

    const githubProvider = new iam.OpenIdConnectProvider(this, "GitHubOIDC", {
      url: "https://token.actions.githubusercontent.com",
      clientIds: ["sts.amazonaws.com"],
      thumbprints: ["6938fd4d98bab03faadb97b34396831e3780aea1"],
    });

    const deployRole = new iam.Role(this, "GitHubActionsDeployRole", {
      assumedBy: new iam.WebIdentityPrincipal(
        githubProvider.openIdConnectProviderArn,
        {
          StringEquals: {
            "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          },
          StringLike: {
            // Restrict to a specific repo and branch
            "token.actions.githubusercontent.com:sub":
              "repo:your-org/your-repo:ref:refs/heads/main",
          },
        }
      ),
      maxSessionDuration: cdk.Duration.hours(1),
    });

    // Grant only what this role needs - ECR push in this case
    deployRole.addToPolicy(
      new iam.PolicyStatement({
        actions: [
          "ecr:GetAuthorizationToken",
          "ecr:BatchCheckLayerAvailability",
          "ecr:PutImage",
          "ecr:InitiateLayerUpload",
          "ecr:UploadLayerPart",
          "ecr:CompleteLayerUpload",
        ],
        resources: ["*"],
      })
    );
  }
}

The sub claim constraint is critical. Without it, any repository in your org could assume this role. You can tighten it further to specific environments or workflow filenames using additional claim conditions.

In your GitHub Actions workflow:

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

permissions:
  id-token: write  # Required for OIDC token request
  contents: read

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

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsDeployRole
          aws-region: us-east-1
          # No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY needed

      - name: Login to ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push image
        run: |
          IMAGE_URI="${{ steps.login-ecr.outputs.registry }}/my-app:${{ github.sha }}"
          docker build -t "$IMAGE_URI" .
          docker push "$IMAGE_URI"

No secrets. The token is valid for the duration of that job and is scoped to exactly what the IAM role permits.

GCP follows the same pattern using Workload Identity Federation with google-github-actions/auth.

Signing Build Artifacts with Sigstore and cosign

Knowing who built an artifact is only useful if you can verify it later. Sigstore’s cosign tool provides keyless signing: instead of managing a private key, cosign uses a short-lived certificate issued by Fulcio (Sigstore’s certificate authority) and records the signature in Rekor (Sigstore’s transparency log).

The signing identity is your OIDC token, so the certificate binds the artifact to the specific GitHub Actions workflow that produced it. Anyone can verify the signature without needing your public key.

Add signing to the workflow after pushing the image:

      - name: Install cosign
        uses: sigstore/cosign-installer@v3

      - name: Sign the container image
        env:
          COSIGN_EXPERIMENTAL: "1"
        run: |
          IMAGE_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE_URI")
          cosign sign --yes "$IMAGE_DIGEST"

The COSIGN_EXPERIMENTAL=1 flag enables keyless signing. cosign requests an OIDC token from GitHub, exchanges it for a short-lived Fulcio certificate, signs the image digest, and records everything in Rekor. The certificate embeds the workflow URL, repo, and SHA as Subject Alternative Name claims.

To verify later:

cosign verify \
  --certificate-identity-regexp "https://github.com/your-org/your-repo/.github/workflows/deploy.yml" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  "$IMAGE_DIGEST"

This gives you cryptographic proof that the image was built by a specific workflow in a specific repository, not just “someone with the registry password.”

Generating SLSA Provenance

Signing proves who signed. SLSA provenance proves how the artifact was built: what source commit, what build system, what commands ran, what inputs were consumed. SLSA (Supply-chain Levels for Software Artifacts) is a framework for expressing and verifying this build provenance.

The slsa-github-generator project makes SLSA Level 3 provenance generation straightforward for GitHub Actions:

# .github/workflows/deploy.yml (extended)
jobs:
  build:
    outputs:
      image-digest: ${{ steps.build.outputs.digest }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsDeployRole
          aws-region: us-east-1

      - name: Build and push
        id: build
        run: |
          IMAGE_URI="${{ steps.login-ecr.outputs.registry }}/my-app"
          docker build -t "$IMAGE_URI:${{ github.sha }}" .
          docker push "$IMAGE_URI:${{ github.sha }}"
          DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE_URI:${{ github.sha }}" | cut -d@ -f2)
          echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"

  provenance:
    needs: [build]
    permissions:
      id-token: write
      contents: read
      actions: read
    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.0.0
    with:
      image: "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app"
      digest: ${{ needs.build.outputs.image-digest }}
      registry-username: ${{ vars.ECR_USERNAME }}
    secrets:
      registry-password: ${{ secrets.ECR_PASSWORD }}

This generates a signed SLSA provenance attestation and attaches it to the image in the registry as an OCI artifact. The attestation contains the builder identity, the source repo and commit, build invocation parameters, and the expected digest of the output image.

You can verify it with:

slsa-verifier verify-image \
  --source-uri github.com/your-org/your-repo \
  --source-branch main \
  "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app@sha256:abc123..."

Enforcing Signed Images in Kubernetes

Signing artifacts is only half the battle. If your Kubernetes cluster admits unsigned images, a deploy of an unverified artifact bypasses all the work upstream. Admission controllers close this gap.

Kyverno is the most approachable option for enforcing cosign signatures at admission time. It integrates directly with Sigstore and requires no external signature verification service at admit time (it fetches attestations from the registry).

# k8s/policies/verify-image-signatures.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signatures
spec:
  validationFailureAction: Enforce
  background: false
  rules:
    - name: check-image-signature
      match:
        any:
          - resources:
              kinds: [Pod]
              namespaces: [production, staging]
      verifyImages:
        - imageReferences:
            - "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app*"
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/your-org/your-repo/.github/workflows/deploy.yml@refs/heads/main"
                    issuer: "https://token.actions.githubusercontent.com"
                    rekor:
                      url: https://rekor.sigstore.dev
          attestations:
            - type: https://slsa.dev/provenance/v1
              attestors:
                - entries:
                    - keyless:
                        subject: "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.0.0"
                        issuer: "https://token.actions.githubusercontent.com"
              conditions:
                - all:
                    - key: "{{ buildDefinition.externalParameters.source.uri }}"
                      operator: Equals
                      value: "git+https://github.com/your-org/your-repo@refs/heads/main"

This policy does three things at admission time: verifies the cosign signature against the expected workflow identity, verifies the SLSA provenance attestation against the generator identity, and checks that the provenance claims the image was built from the expected source. Any Pod in production or staging that references an image failing these checks is rejected before it can schedule.

For teams using Sigstore’s Policy Controller (Cosign’s native Kubernetes integration) rather than Kyverno, the equivalent is a ClusterImagePolicy resource. The model is the same; the syntax differs.

Tradeoffs

ApproachSecurity gainOperational costNotes
OIDC federation onlyHigh - no static credentialsLow - one-time IAM setupBest first step, immediate value
cosign keyless signingMedium - provenance on who signedLow - adds ~30s to pipelineRequires registry OCI 1.1 support
SLSA provenanceHigh - full build traceMedium - external reusable workflowGenerator workflow must be pinned by digest
Kyverno admission enforcementHigh - runtime gateMedium - policy tuning, false positives earlyStart in Audit mode, then Enforce
Full stack (all four)Very high - end-to-end verifiableMedium-high initiallyStandard for regulated industries, worth it for most production systems

Production Considerations

Registry support. OCI 1.1 referrers API is required for attaching attestations as separate artifacts. ECR, GCR/Artifact Registry, and GitHub Container Registry all support this. Some self-hosted registries lag behind.

Key rotation and certificate pinning. Keyless signing avoids long-term key management, but you should pin the Rekor and Fulcio URLs in your verification config and monitor for Sigstore root certificate changes.

Policy bootstrapping. Running Kyverno in Enforce mode from day one on an existing cluster will break deployments that predate signing. Run in Audit mode first, sign all current images, verify the audit log shows zero violations, then flip to Enforce.

Egress to Rekor. cosign sign and cosign verify both make outbound requests to rekor.sigstore.dev by default. In air-gapped or restricted environments, you need a self-hosted Rekor instance or you need to cache signatures locally.

OIDC claim scoping in monorepos. The sub claim in GitHub Actions OIDC tokens can match on workflow file path. In a monorepo where multiple services deploy from different workflow files, create separate IAM roles per workflow to maintain least-privilege separation.

Image digest pinning in manifests. Kyverno verifies the image at admission time. If your Kubernetes manifests reference images by tag rather than digest, a tag can be reassigned between policy check and pod start. Always pin to digest in production manifests and use a tool like Renovate to keep them current.

// scripts/resolve-digest.ts - utility to resolve tag to digest for manifest pinning
import { execSync } from "child_process";

function resolveImageDigest(imageWithTag: string): string {
  const output = execSync(
    `docker buildx imagetools inspect ${imageWithTag} --format '{{json .Manifest.Digest}}'`,
    { encoding: "utf-8" }
  ).trim();

  const digest = JSON.parse(output) as string;
  const [repoWithTag] = imageWithTag.split(":");
  return `${repoWithTag}@${digest}`;
}

// Example: resolve before writing to Kubernetes manifests
const pinned = resolveImageDigest(
  "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:latest"
);
console.log(pinned);
// 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app@sha256:abc123...

Runtime attestation beyond admission. Falco can enforce runtime behavior policies inside running containers - detecting unexpected network connections, file writes to sensitive paths, or privilege escalation attempts. It complements admission control: admission verifies the artifact is trusted, runtime monitoring verifies it is behaving as expected. The combination matters because a trusted image can still be exploited after deployment.

Closing

The full chain - OIDC federation, cosign signing, SLSA provenance, and admission enforcement - sounds like a lot of infrastructure. In practice, OIDC federation is an afternoon of IAM configuration with immediate security payoff. Signing and provenance add minutes to your pipeline and a few days of policy work. The marginal cost of each layer is low once the previous one is in place.

The alternative is a pipeline where any compromised credential, any malicious dependency, or any confused CI runner can push an unverifiable artifact to production with no record of how it got there. That is a fine posture until the incident review asks “how did this image end up running in prod?”

The answer should be cryptographically provable, not a matter of trust.

More in DevOps

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
DevOps ·

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code

A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
DevOps ·

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container

A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
DevOps ·

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop

A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
DevOps ·

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution

A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.