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:
- CI/CD platform issues a signed JWT (identity token)
- Token contains claims about the workflow (repo, branch, actor)
- Cloud provider validates token signature and claims
- 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:
-
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.
-
Set aggressive credential lifetimes. Request 15-minute credentials for builds, not 1-hour. Shorter is better.
-
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:
-
Build identity infrastructure. Create OIDC providers, IAM roles, and trust policies as reusable templates. Make identity-based auth the easy path.
-
Implement policy-as-code. Use OPA or similar to enforce security policies programmatically. Manual review doesn't scale.
-
Monitor identity usage. Track which identities access which resources. Anomalies indicate either misconfiguration or compromise.
For Security Architects:
-
Eliminate stored secrets systematically. Audit all CI/CD secrets. For each one, determine if identity-based authentication can replace it.
-
Design for continuous verification. Authentication at the start isn't enough. Verify at each stage: trigger, authorization, runtime, and artifacts.
-
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.