Skip to content

17.2 Zero Trust for CI/CD Pipelines

For years, CI/CD security meant storing long-lived secrets—AWS access keys, service account tokens, deployment credentials—in pipeline configuration. These secrets never expire, work from anywhere, and provide full access once compromised. When attackers breached Codecov in 2021, they harvested CI/CD secrets from thousands of organizations. Those secrets remained valid until manually rotated—if organizations even knew to rotate them. Zero trust architecture eliminates this vulnerability by replacing stored secrets with identity-based authentication: build jobs prove who they are and receive short-lived credentials valid only for that specific execution.

This section applies zero trust principles to CI/CD pipelines, demonstrating how to move from secrets-based to identity-based authentication using OIDC federation and workload identity.

Zero Trust Principles for CI/CD

Zero trust assumes no implicit trust based on network location or prior authentication. Every request must be authenticated, authorized, and continuously validated.

Traditional CI/CD Trust Model:

┌─────────────────────────────────────────────────────────────────┐
│                    TRADITIONAL MODEL                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   ┌─────────────┐      Long-lived       ┌─────────────────┐    │
│   │  CI/CD      │      Secret           │  Cloud          │    │
│   │  Pipeline   │ ───────────────────►  │  Provider       │    │
│   └─────────────┘                       └─────────────────┘    │
│                                                                  │
│   Problems:                                                      │
│   • Secret never expires                                        │
│   • Works from any context                                      │
│   • Compromised = persistent access                             │
│   • Rotation is manual and error-prone                          │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Zero Trust CI/CD Model:

┌─────────────────────────────────────────────────────────────────┐
│                    ZERO TRUST MODEL                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   ┌─────────────┐   1. Request token   ┌─────────────────┐     │
│   │  CI/CD      │ ◄──────────────────► │  Identity       │     │
│   │  Pipeline   │   (OIDC)             │  Provider       │     │
│   └──────┬──────┘                      └─────────────────┘     │
│          │                                                       │
│          │ 2. Present token + policy check                      │
│          ▼                                                       │
│   ┌─────────────────┐   3. Short-lived  ┌─────────────────┐    │
│   │  Cloud          │ ◄───────────────► │  Policy         │    │
│   │  Provider       │      Credential   │  Engine         │    │
│   └─────────────────┘                   └─────────────────┘    │
│                                                                  │
│   Benefits:                                                      │
│   • Credentials expire (minutes/hours)                          │
│   • Context validated (repo, branch, workflow)                  │
│   • Compromised = limited window                                │
│   • No secrets to rotate                                        │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Core Principles Applied:

Principle Traditional CI/CD Zero Trust CI/CD
Verify explicitly Trust stored secret Verify identity each time
Least privilege Broad, static permissions Scoped, dynamic permissions
Assume breach Secrets valid indefinitely Credentials expire rapidly
Never trust Network location trusted Every request authenticated

Organizations that migrate to OIDC federation report significant security benefits when credential breaches occur elsewhere in their industry—they have nothing to rotate because every credential has already expired.

OIDC Federation for Cloud Access

OpenID Connect (OIDC) federation enables CI/CD platforms to issue identity tokens that cloud providers accept, eliminating the need for stored cloud credentials.

How OIDC Federation Works:

  1. CI/CD platform issues a signed JWT (identity token)
  2. Token contains claims about the workflow (repo, branch, actor)
  3. Cloud provider validates token signature and claims
  4. Cloud provider issues short-lived credentials if policy allows

GitHub Actions → AWS:

Step 1: Create OIDC Identity Provider in AWS

# Create the OIDC provider
# Note: As of June 2023, AWS recommends two thumbprints for GitHub Actions
aws iam create-open-id-connect-provider \
    --url https://token.actions.githubusercontent.com \
    --thumbprint-list "6938fd4d98bab03faadb97b34396831e3780aea1" "1c58a3a8518e8759bf075b76b750d4f2df264fcd" \
    --client-id-list "sts.amazonaws.com"

Step 2: Create IAM Role with Trust Policy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:*"
        }
      }
    }
  ]
}

Step 3: Use in GitHub Actions Workflow

name: Deploy
on:
  push:
    branches: [main]

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

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/GitHubActionsRole
          aws-region: us-east-1
          # No access key ID or secret access key stored!

      - run: aws s3 ls  # Works with temporary credentials

GitHub Actions → Google Cloud:

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: 'projects/123456/locations/global/workloadIdentityPools/github/providers/github'
          service_account: 'github-actions@project.iam.gserviceaccount.com'

      - uses: google-github-actions/setup-gcloud@v2

      - run: gcloud storage ls

GitHub Actions → Azure:

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: azure/login@v1
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
          # Note: client-id and tenant-id are not secrets (public identifiers)
          # No client secret stored!

GitLab CI → AWS:

deploy:
  image: amazon/aws-cli
  id_tokens:
    AWS_TOKEN:
      aud: https://gitlab.com
  script:
    - >
      export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s"
      $(aws sts assume-role-with-web-identity
      --role-arn arn:aws:iam::123456789:role/GitLabRole
      --role-session-name "GitLabSession"
      --web-identity-token $AWS_TOKEN
      --duration-seconds 3600
      --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
      --output text))
    - aws s3 ls

Short-Lived Credentials

Short-lived credentials limit the window of opportunity for attackers.

Credential Lifetime Recommendations:

Context Recommended Lifetime Rationale
Build job 15-60 minutes Matches typical job duration
Deployment 15-30 minutes Matches deployment window
Cross-account access 1 hour maximum Balance convenience and security
Production access As short as practical Higher risk requires tighter controls

AWS STS Configuration:

# GitHub Actions: Request short-lived credentials
- uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789:role/DeployRole
    aws-region: us-east-1
    role-duration-seconds: 900  # 15 minutes
    role-session-name: deploy-${{ github.run_id }}

Service Account Token Projection (Kubernetes):

apiVersion: v1
kind: Pod
spec:
  serviceAccountName: build-agent
  containers:
    - name: build
      volumeMounts:
        - name: token
          mountPath: /var/run/secrets/tokens
  volumes:
    - name: token
      projected:
        sources:
          - serviceAccountToken:
              path: token
              expirationSeconds: 600  # 10 minutes
              audience: build-system

Automatic Credential Refresh:

# Python: Use SDK automatic refresh
import boto3
from botocore.credentials import RefreshableCredentials
from botocore.session import get_session

def get_refreshable_session():
    """Create session with automatic credential refresh."""
    session = get_session()

    def refresh():
        # STS AssumeRoleWithWebIdentity call
        credentials = assume_role_with_web_identity()
        return {
            'access_key': credentials['AccessKeyId'],
            'secret_key': credentials['SecretAccessKey'],
            'token': credentials['SessionToken'],
            'expiry_time': credentials['Expiration'].isoformat()
        }

    refreshable_credentials = RefreshableCredentials.create_from_metadata(
        metadata=refresh(),
        refresh_using=refresh,
        method='sts-assume-role-with-web-identity'
    )

    session._credentials = refreshable_credentials
    return boto3.Session(botocore_session=session)

Workload Identity Implementation

Workload identity assigns verifiable identity to build jobs, enabling fine-grained access control.

GitHub Actions OIDC Claims:

The identity token contains claims that can be used for policy decisions:

{
  "iss": "https://token.actions.githubusercontent.com",
  "sub": "repo:myorg/myrepo:ref:refs/heads/main",
  "aud": "sts.amazonaws.com",
  "ref": "refs/heads/main",
  "sha": "abc123...",
  "repository": "myorg/myrepo",
  "repository_owner": "myorg",
  "actor": "username",
  "workflow": "Deploy",
  "event_name": "push",
  "ref_type": "branch",
  "job_workflow_ref": "myorg/myrepo/.github/workflows/deploy.yml@refs/heads/main",
  "runner_environment": "github-hosted"
}

Fine-Grained Trust Policies:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/heads/main"
        }
      }
    }
  ]
}

Condition Examples:

Condition Effect
repo:myorg/myrepo:* Any branch in specific repo
repo:myorg/myrepo:ref:refs/heads/main Only main branch
repo:myorg/myrepo:environment:production Only production environment
repo:myorg/*:ref:refs/heads/main Main branch across org

GCP Workload Identity Federation:

# Create workload identity pool
gcloud iam workload-identity-pools create "github-pool" \
  --location="global" \
  --description="GitHub Actions pool"

# Create provider
gcloud iam workload-identity-pools providers create-oidc "github-provider" \
  --location="global" \
  --workload-identity-pool="github-pool" \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository"

# Grant access to service account
gcloud iam service-accounts add-iam-policy-binding \
  "github-actions@project.iam.gserviceaccount.com" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/github-pool/attribute.repository/myorg/myrepo"

Continuous Verification

Zero trust requires verification throughout the build lifecycle, not just at the start.

Verification Points:

Point Verification
Trigger Is this a legitimate trigger? (webhook signature, source)
Authentication Is the identity valid? (OIDC token validation)
Authorization Is this identity allowed this action? (policy check)
Runtime Are actions within expected bounds? (behavioral analysis)
Artifacts Are outputs integrity-verified? (signing, provenance)

Webhook Signature Verification:

import hmac
import hashlib

def verify_github_webhook(payload: bytes, signature: str, secret: str) -> bool:
    """Verify GitHub webhook signature."""
    expected = 'sha256=' + hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

# In webhook handler
@app.route('/webhook', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Hub-Signature-256')
    if not verify_github_webhook(request.data, signature, WEBHOOK_SECRET):
        return 'Invalid signature', 403

    # Process legitimate webhook
    return process_webhook(request.json)

Runtime Policy Enforcement:

# OPA policy for build job verification
package cicd.build

default allow = false

# Allow build if conditions met
allow {
    input.repository == "myorg/myrepo"
    input.ref == "refs/heads/main"
    input.workflow_ref == ".github/workflows/deploy.yml"
    input.runner_environment == "github-hosted"
    input.actor_is_org_member == true
}

# Deny if suspicious patterns
deny[msg] {
    input.ref != "refs/heads/main"
    input.environment == "production"
    msg := "Production deployment only allowed from main branch"
}

deny[msg] {
    input.runner_environment == "self-hosted"
    input.environment == "production"
    msg := "Production deployment requires GitHub-hosted runners"
}

Artifact Verification:

# GitHub Actions: Sign and verify artifacts
jobs:
  build:
    outputs:
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/attest-build-provenance@v1
        with:
          subject-path: 'dist/artifact.tar.gz'

  deploy:
    needs: build
    steps:
      - uses: actions/download-artifact@v4

      # Verify before deployment
      - run: |
          gh attestation verify dist/artifact.tar.gz \
            --owner myorg \
            --repo myrepo

Policy-as-Code for Build Pipelines

Encode security policies as code for consistent enforcement.

Open Policy Agent (OPA) for CI/CD:

# policy/cicd.rego
package cicd

# Enforce branch protection
deny[msg] {
    input.event == "push"
    input.ref == "refs/heads/main"
    not input.pull_request_merged
    msg := "Direct push to main not allowed"
}

# Require signed commits
deny[msg] {
    input.event == "pull_request"
    commit := input.commits[_]
    not commit.verified
    msg := sprintf("Commit %s is not signed", [commit.sha])
}

# Enforce deployment approval
deny[msg] {
    input.environment == "production"
    not input.deployment_approved
    msg := "Production deployment requires approval"
}

# Restrict which workflows can deploy
allow_deploy {
    input.workflow_ref == ".github/workflows/deploy.yml"
    input.repository == "myorg/myrepo"
}

deny[msg] {
    input.action == "deploy"
    not allow_deploy
    msg := "Unauthorized workflow attempting deployment"
}

Policy Enforcement in Pipeline:

# GitHub Actions: Policy check before deployment
jobs:
  policy-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Check policies
        uses: open-policy-agent/opa-action@v2
        with:
          policy: policy/cicd.rego
          input: |
            {
              "event": "${{ github.event_name }}",
              "ref": "${{ github.ref }}",
              "repository": "${{ github.repository }}",
              "actor": "${{ github.actor }}",
              "workflow_ref": "${{ github.workflow_ref }}",
              "environment": "production"
            }
          fail-on-deny: true

  deploy:
    needs: policy-check
    # Only runs if policy check passes

Conftest for Workflow Validation:

# Validate CI/CD configuration against policies
conftest test .github/workflows/*.yml -p policy/

# Example policy for workflow validation
package main

deny[msg] {
    input.jobs[_].runs-on == "self-hosted"
    msg := "Self-hosted runners not allowed for this repository"
}

deny[msg] {
    input.permissions == "write-all"
    msg := "write-all permissions not allowed"
}

Migration from Secrets to Identity

Moving from stored secrets to identity-based authentication requires a phased approach.

Migration Phases:

Phase 1: Inventory and Assessment

# Secret Inventory

| Secret Name | Type | Age | Used In | Can Use OIDC? |
|-------------|------|-----|---------|---------------|
| AWS_ACCESS_KEY | Cloud | 2 years | deploy.yml | Yes |
| DOCKER_PASSWORD | Registry | 1 year | build.yml | Yes (token) |
| NPM_TOKEN | Package | 6 months | publish.yml | Yes (granular) |
| SIGNING_KEY | Signing | 3 years | release.yml | No (hardware) |

Phase 2: Parallel Implementation

# Run both methods during transition
jobs:
  deploy-new:
    if: ${{ vars.USE_OIDC == 'true' }}
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}

  deploy-legacy:
    if: ${{ vars.USE_OIDC != 'true' }}
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

Phase 3: Validation and Cutover

# Verify OIDC works before removing secrets
steps:
  - name: Test OIDC authentication
    id: test-oidc
    uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: ${{ vars.AWS_ROLE_ARN }}
    continue-on-error: true

  - name: Verify success
    if: steps.test-oidc.outcome == 'success'
    run: echo "OIDC working - safe to remove secrets"

  - name: Alert on failure
    if: steps.test-oidc.outcome == 'failure'
    run: |
      echo "::error::OIDC authentication failed"
      exit 1

Phase 4: Secret Removal and Monitoring

# Remove secrets only after OIDC verified
# Monitor for any failures post-migration

# GitHub CLI: Delete secret (after verification)
gh secret delete AWS_ACCESS_KEY_ID
gh secret delete AWS_SECRET_ACCESS_KEY

Migration Checklist:

## OIDC Migration Checklist

### Pre-Migration
- [ ] Inventory all stored secrets
- [ ] Identify which can use OIDC
- [ ] Design IAM roles with appropriate trust policies
- [ ] Create OIDC providers in cloud accounts
- [ ] Document rollback procedure

### Migration
- [ ] Deploy OIDC configuration in parallel
- [ ] Test in non-production first
- [ ] Validate authentication works
- [ ] Monitor for issues
- [ ] Gradually shift traffic to OIDC

### Post-Migration
- [ ] Remove legacy secrets
- [ ] Update documentation
- [ ] Brief team on new approach
- [ ] Monitor authentication metrics
- [ ] Schedule periodic review

Recommendations

For DevOps Engineers:

  1. Start with OIDC for cloud access. Replace AWS access keys, GCP service account keys, and Azure service principals with OIDC federation. This is the highest-impact change you can make.

  2. Set aggressive credential lifetimes. Request 15-minute credentials for builds, not 1-hour. Shorter is better.

  3. Use claim-based trust policies. Don't allow any repository to assume your roles. Specify exact repository, branch, and environment in trust policies.

For Platform Engineers:

  1. Build identity infrastructure. Create OIDC providers, IAM roles, and trust policies as reusable templates. Make identity-based auth the easy path.

  2. Implement policy-as-code. Use OPA or similar to enforce security policies programmatically. Manual review doesn't scale.

  3. Monitor identity usage. Track which identities access which resources. Anomalies indicate either misconfiguration or compromise.

For Security Architects:

  1. Eliminate stored secrets systematically. Audit all CI/CD secrets. For each one, determine if identity-based authentication can replace it.

  2. Design for continuous verification. Authentication at the start isn't enough. Verify at each stage: trigger, authorization, runtime, and artifacts.

  3. Plan the migration. Moving from secrets to identity is a journey. Phase it to manage risk while maintaining velocity.

Zero trust transforms CI/CD security from "protect the secrets" to "verify the identity." When there are no long-lived secrets to steal, credential theft becomes impossible. When every action is authenticated and authorized based on verifiable identity, attackers must compromise the identity system itself—a much harder target than a stored secret.