Short-Lived Credentials and Workload Identity Federation in CI/CD Pipelines

Introduction

If you audit the secret stores of most CI/CD platforms today, you will find a graveyard of long-lived credentials: AWS access keys created years ago, GCP service account JSON keys shared across dozens of pipelines, GitHub Personal Access Tokens with broad scopes, and database passwords that have never been rotated. These static secrets are the single most common attack vector in CI/CD compromises.

The reason is straightforward. A long-lived credential is a skeleton key. Once an attacker obtains it — through a leaked log, a compromised dependency, a misconfigured secret store, or a supply chain attack on the CI platform itself — they have persistent, often over-privileged access to production infrastructure. There is no expiration clock ticking. There is no automatic revocation. The attacker can use that credential from any IP, any context, for as long as it takes the defending team to notice.

Workload identity federation changes the equation entirely. Instead of injecting static secrets into pipeline runs, the CI platform itself becomes an identity provider. Each pipeline run receives a short-lived, cryptographically signed token that proves what is running (which repository, which branch, which workflow, which environment). Cloud providers validate that token and issue temporary credentials scoped to exactly the permissions needed — credentials that expire in minutes, not months.

This guide walks through the problem in detail, explains how workload identity federation works at the protocol level, provides complete working examples for GitHub Actions and GitLab CI across AWS, GCP, and Azure, covers advanced patterns, and includes a practical migration guide for teams ready to eliminate their long-lived secrets.

The Problem with Long-Lived Credentials

Before diving into the solution, it is worth understanding exactly why long-lived credentials are so dangerous in CI/CD contexts specifically — not just in general.

No Expiration or Automatic Rotation

An AWS IAM access key, once created, is valid forever unless explicitly revoked. A GCP service account JSON key has no expiration date. A GitHub PAT can be set to never expire. In practice, most teams create these credentials once during initial setup and never touch them again. The median age of a CI/CD secret in most organizations is measured in years.

This means that even if a credential was leaked six months ago, it is still valid today. Attackers know this and routinely scan public repositories, Docker images, and CI logs for credentials that may have been exposed at any point in history.

Broad Blast Radius

CI/CD credentials tend to be over-privileged because they need to perform diverse tasks: build containers, push to registries, deploy infrastructure, run database migrations, invalidate caches. Rather than creating narrowly scoped credentials for each task, teams typically create one powerful credential and reuse it everywhere. A single leaked key can grant access to production databases, cloud infrastructure, and deployment pipelines simultaneously.

Difficult to Audit

When the same service account key is used across 50 repositories, 200 pipelines, and three environments, it becomes nearly impossible to answer basic security questions:

  • Which pipeline made this API call to production at 3 AM?
  • Was this credential used from an authorized CI runner or from an attacker’s machine?
  • Which repositories still depend on this key if we need to rotate it?

Long-lived credentials provide no contextual information about who or what is using them. Every use looks identical in audit logs.

Stored in CI Platform Secret Stores

CI platforms like GitHub Actions, GitLab CI, and Jenkins store secrets in their own vaults. These are valuable targets. A single breach of the CI platform’s secret store exposes every credential for every project. The CircleCI breach in January 2023 is a textbook example: attackers compromised CircleCI’s internal systems and exfiltrated customer secrets, forcing every CircleCI customer to rotate every secret stored in the platform.

Real-World Breaches

The pattern repeats across the industry:

  • Codecov (2021): Attackers modified the Codecov Bash Uploader to exfiltrate environment variables — including CI/CD secrets — from thousands of customers’ pipelines. Long-lived credentials stored as environment variables were sent to attacker-controlled servers.
  • CircleCI (2023): A compromised employee laptop led to the exfiltration of customer secrets from CircleCI’s secret storage. Every customer was advised to rotate all secrets immediately.
  • Travis CI (2021): A vulnerability exposed secrets from public repositories’ builds, including AWS keys, GitHub tokens, and Docker Hub credentials.
  • Uber (2022): An attacker gained access to internal systems through a compromised CI/CD pipeline, leveraging hardcoded credentials found in PowerShell scripts.

In every case, the root cause was the same: long-lived credentials stored in CI environments provided persistent, over-privileged access that attackers could exploit long after the initial compromise.

How Workload Identity Federation Works

Workload identity federation replaces static credentials with a dynamic, token-based authentication flow built on OpenID Connect (OIDC). Here is how it works at the protocol level.

The OIDC Flow

The authentication flow involves three parties: the CI platform (identity provider), the cloud provider (relying party), and the pipeline run (workload).

  1. Token issuance: When a pipeline job starts, the CI platform generates a signed JWT (JSON Web Token) for that specific run. This token contains claims about the workload: which repository triggered it, which branch, which workflow, which actor, and the environment.
  2. Token exchange: The pipeline presents this JWT to the cloud provider’s Security Token Service (STS) and requests temporary credentials.
  3. Validation: The cloud provider fetches the CI platform’s public OIDC discovery document and signing keys. It validates the JWT signature, checks that the token hasn’t expired, and verifies the claims match the configured trust policy.
  4. Credential issuance: If validation passes, the cloud provider issues short-lived credentials (typically valid for 1 hour or less) scoped to the IAM role or service account configured in the trust policy.

The Trust Relationship

The foundation of workload identity federation is a trust relationship between the cloud provider’s IAM system and the CI platform’s OIDC provider. This is configured once:

  • AWS: An IAM OIDC Identity Provider resource that trusts token.actions.githubusercontent.com (for GitHub Actions) or gitlab.com (for GitLab).
  • GCP: A Workload Identity Pool and Provider configured to trust the CI platform’s OIDC issuer.
  • Azure: A Federated Identity Credential on an App Registration or Managed Identity.

Claims-Based Scoping

The real security power of OIDC federation comes from claims-based scoping. The JWT issued by the CI platform contains rich contextual claims. You configure the cloud provider to only accept tokens whose claims match specific conditions:

  • Repository: Only accept tokens from my-org/my-repo
  • Branch: Only accept tokens from the main branch
  • Environment: Only accept tokens from the production environment
  • Workflow: Only accept tokens from a specific workflow file

This means that even if an attacker compromises a different repository in the same GitHub organization, they cannot obtain credentials scoped to the production deployment role — the claims won’t match.

The Flow Visualized

┌─────────────┐     1. Request JWT      ┌──────────────────┐
│  CI Runner   │ ──────────────────────▶ │  CI OIDC Provider │
│  (Pipeline)  │ ◀────────────────────── │  (GitHub/GitLab)  │
└─────┬───────┘     2. Signed JWT        └──────────────────┘
      │                                          │
      │  3. Present JWT +                        │
      │     Request credentials                  │
      ▼                                          │
┌─────────────┐     4. Validate JWT      ┌──────┴───────────┐
│  Cloud IAM  │ ──────────────────────▶  │  OIDC Discovery   │
│  (STS)      │     (fetch public keys)  │  + JWKS Endpoint  │
└─────┬───────┘                          └──────────────────┘
      │
      │  5. Issue short-lived
      │     credentials (1hr)
      ▼
┌─────────────┐
│  Cloud APIs │
│  (S3, GCS,  │
│   etc.)     │
└─────────────┘

GitHub Actions OIDC Federation

GitHub Actions has native OIDC support. Every workflow run can request a signed JWT from GitHub’s OIDC provider at token.actions.githubusercontent.com. Here is how to set up federation with each major cloud provider.

AWS: IAM OIDC Provider + IAM Role

First, create the OIDC provider and IAM role in AWS. Here is the Terraform configuration:

# Terraform: AWS OIDC Provider for GitHub Actions
resource "aws_iam_openid_connect_provider" "github_actions" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

# IAM Role that GitHub Actions can assume
resource "aws_iam_role" "github_actions_deploy" {
  name = "github-actions-deploy"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Federated = aws_iam_openid_connect_provider.github_actions.arn
        }
        Action = "sts:AssumeRoleWithWebIdentity"
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          }
          StringLike = {
            "token.actions.githubusercontent.com:sub" = "repo:my-org/my-repo:ref:refs/heads/main"
          }
        }
      }
    ]
  })
}

# Attach permissions to the role
resource "aws_iam_role_policy_attachment" "deploy_policy" {
  role       = aws_iam_role.github_actions_deploy.name
  policy_arn = "arn:aws:iam::policy/my-deploy-policy"
}

Then use the role in your GitHub Actions workflow:

# .github/workflows/deploy.yml
name: Deploy to AWS
on:
  push:
    branches: [main]

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

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

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: us-east-1
          role-session-name: github-actions-deploy-${{ github.run_id }}

      - name: Deploy
        run: |
          aws s3 sync ./dist s3://my-bucket/
          aws cloudfront create-invalidation --distribution-id E12345 --paths "/*"

GCP: Workload Identity Pool + Provider

GCP uses Workload Identity Federation with pools and providers. Here is the setup with gcloud CLI:

# Create the Workload Identity Pool
gcloud iam workload-identity-pools create "github-actions-pool" \
  --project="my-project" \
  --location="global" \
  --display-name="GitHub Actions Pool"

# Create the OIDC Provider
gcloud iam workload-identity-pools providers create-oidc "github-provider" \
  --project="my-project" \
  --location="global" \
  --workload-identity-pool="github-actions-pool" \
  --display-name="GitHub OIDC" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --attribute-condition="assertion.repository_owner=='my-org'"

# Grant the pool access to a service account
gcloud iam service-accounts add-iam-policy-binding \
  deploy-sa@my-project.iam.gserviceaccount.com \
  --project="my-project" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-actions-pool/attribute.repository/my-org/my-repo"

The GitHub Actions workflow for GCP:

# .github/workflows/deploy-gcp.yml
name: Deploy to GCP
on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

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

      - name: Authenticate to GCP
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: "projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-actions-pool/providers/github-provider"
          service_account: "deploy-sa@my-project.iam.gserviceaccount.com"

      - name: Set up Cloud SDK
        uses: google-github-actions/setup-gcloud@v2

      - name: Deploy to Cloud Run
        run: |
          gcloud run deploy my-service \
            --image gcr.io/my-project/my-app:${{ github.sha }} \
            --region us-central1

Azure: Federated Credentials on App Registration

Azure supports federated identity credentials on App Registrations and Managed Identities. Create the federation using Azure CLI:

# Create an App Registration
az ad app create --display-name "github-actions-deploy"

# Create a federated credential
az ad app federated-credential create \
  --id <APP_OBJECT_ID> \
  --parameters '{
    "name": "github-actions-main",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:my-org/my-repo:ref:refs/heads/main",
    "audiences": ["api://AzureADTokenExchange"],
    "description": "GitHub Actions deploy from main branch"
  }'

# Create a service principal and assign roles
az ad sp create --id <APP_CLIENT_ID>
az role assignment create \
  --assignee <APP_CLIENT_ID> \
  --role "Contributor" \
  --scope /subscriptions/<SUBSCRIPTION_ID>/resourceGroups/my-rg

The GitHub Actions workflow for Azure:

# .github/workflows/deploy-azure.yml
name: Deploy to Azure
on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

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

      - name: Azure Login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Deploy to Azure Web App
        uses: azure/webapps-deploy@v3
        with:
          app-name: my-web-app
          package: ./dist

Note: With Azure OIDC, you still store the client ID, tenant ID, and subscription ID as secrets — but these are non-sensitive identifiers, not authentication credentials. No client secret is needed.

Claim Conditions: Restricting Access

The subject claim in GitHub Actions OIDC tokens follows a predictable format. Use these patterns in your trust policies:

  • repo:my-org/my-repo:ref:refs/heads/main — Only the main branch
  • repo:my-org/my-repo:environment:production — Only the production environment
  • repo:my-org/my-repo:pull_request — Only pull request workflows
  • repo:my-org/my-repo:ref:refs/tags/v* — Only version tags (use StringLike in AWS)

Always use the most restrictive claim condition possible. Avoid wildcards like repo:my-org/* unless you genuinely need org-wide access.

GitLab CI OIDC Federation

GitLab CI introduced native OIDC support with the id_tokens keyword. The flow is similar to GitHub Actions but with some differences in claim structure and configuration.

Requesting OIDC Tokens in GitLab CI

In GitLab CI, you declare OIDC tokens in your job configuration using the id_tokens keyword:

# .gitlab-ci.yml
deploy_to_aws:
  stage: deploy
  image: amazon/aws-cli:latest
  id_tokens:
    AWS_OIDC_TOKEN:
      aud: https://sts.amazonaws.com
  script:
    - |
      CREDENTIALS=$(aws sts assume-role-with-web-identity \
        --role-arn arn:aws:iam::123456789012:role/gitlab-deploy \
        --role-session-name "gitlab-ci-${CI_PIPELINE_ID}" \
        --web-identity-token "${AWS_OIDC_TOKEN}" \
        --duration-seconds 3600 \
        --query 'Credentials')
      export AWS_ACCESS_KEY_ID=$(echo $CREDENTIALS | jq -r '.AccessKeyId')
      export AWS_SECRET_ACCESS_KEY=$(echo $CREDENTIALS | jq -r '.SecretAccessKey')
      export AWS_SESSION_TOKEN=$(echo $CREDENTIALS | jq -r '.SessionToken')
      aws s3 sync ./dist s3://my-bucket/
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

AWS Configuration for GitLab

The IAM OIDC provider for GitLab uses a different issuer URL:

# Terraform: AWS OIDC Provider for GitLab
resource "aws_iam_openid_connect_provider" "gitlab" {
  url             = "https://gitlab.com"
  client_id_list  = ["https://sts.amazonaws.com"]
  thumbprint_list = ["b3dd7606d2b5a8b4a13771dbecc9ee1cecafa38a"]
}

resource "aws_iam_role" "gitlab_deploy" {
  name = "gitlab-deploy"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Federated = aws_iam_openid_connect_provider.gitlab.arn
        }
        Action = "sts:AssumeRoleWithWebIdentity"
        Condition = {
          StringEquals = {
            "gitlab.com:aud" = "https://sts.amazonaws.com"
          }
          StringLike = {
            "gitlab.com:sub" = "project_path:my-group/my-project:ref_type:branch:ref:main"
          }
        }
      }
    ]
  })
}

GCP Workload Identity Federation for GitLab

# Create the OIDC Provider for GitLab
gcloud iam workload-identity-pools providers create-oidc "gitlab-provider" \
  --project="my-project" \
  --location="global" \
  --workload-identity-pool="cicd-pool" \
  --display-name="GitLab OIDC" \
  --attribute-mapping="google.subject=assertion.sub,attribute.project_path=assertion.project_path,attribute.ref=assertion.ref" \
  --issuer-uri="https://gitlab.com" \
  --attribute-condition="assertion.namespace_path=='my-group'"

The corresponding GitLab CI job for GCP:

# .gitlab-ci.yml
deploy_to_gcp:
  stage: deploy
  image: google/cloud-sdk:slim
  id_tokens:
    GCP_OIDC_TOKEN:
      aud: https://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/cicd-pool/providers/gitlab-provider
  script:
    - |
      echo "${GCP_OIDC_TOKEN}" > /tmp/oidc_token.txt
      gcloud iam workload-identity-pools create-cred-config \
        "projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/cicd-pool/providers/gitlab-provider" \
        --service-account="deploy-sa@my-project.iam.gserviceaccount.com" \
        --output-file=/tmp/cred_config.json \
        --credential-source-file=/tmp/oidc_token.txt
      gcloud auth login --cred-file=/tmp/cred_config.json
      gcloud run deploy my-service --image gcr.io/my-project/my-app:${CI_COMMIT_SHA} --region us-central1
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Claim Filtering Differences vs GitHub Actions

GitLab OIDC tokens use a different subject claim format than GitHub Actions:

  • GitLab: project_path:my-group/my-project:ref_type:branch:ref:main
  • GitHub: repo:my-org/my-repo:ref:refs/heads/main

GitLab also includes additional claims that can be used for filtering:

  • namespace_id and namespace_path — The group or user namespace
  • project_id and project_path — The specific project
  • pipeline_source — How the pipeline was triggered (push, merge_request, schedule, etc.)
  • environment — The deployment environment, if set
  • ref_protected — Whether the ref is a protected branch

The ref_protected claim is particularly useful: you can configure trust policies that only accept tokens from protected branches, adding an extra layer of security.

Advanced Patterns

Chaining OIDC: CI to Vault to Cloud

HashiCorp Vault can act as an intermediate identity broker between your CI platform and cloud providers. This is useful when you need centralized secret management, dynamic credentials for databases or other non-cloud services, or a unified audit trail across all CI platforms.

# Configure Vault JWT auth backend for GitHub Actions
vault auth enable jwt

vault write auth/jwt/config \
  oidc_discovery_url="https://token.actions.githubusercontent.com" \
  bound_issuer="https://token.actions.githubusercontent.com"

# Create a role that maps GitHub Actions claims to Vault policies
vault write auth/jwt/role/deploy \
  role_type="jwt" \
  bound_audiences="https://vault.mycompany.com" \
  bound_claims_type="glob" \
  bound_claims='{"repository":"my-org/my-repo","ref":"refs/heads/main"}' \
  user_claim="repository" \
  policies="deploy-policy" \
  ttl="15m"

The GitHub Actions workflow using Vault as a broker:

# .github/workflows/deploy-via-vault.yml
name: Deploy via Vault
on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

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

      - name: Import Secrets from Vault
        uses: hashicorp/vault-action@v3
        with:
          url: https://vault.mycompany.com
          method: jwt
          role: deploy
          jwtGithubAudience: https://vault.mycompany.com
          secrets: |
            aws/creds/deploy access_key | AWS_ACCESS_KEY_ID ;
            aws/creds/deploy secret_key | AWS_SECRET_ACCESS_KEY ;
            aws/creds/deploy security_token | AWS_SESSION_TOKEN

      - name: Deploy
        run: aws s3 sync ./dist s3://my-bucket/

Per-Environment Identities

Create separate IAM roles for each environment, each with progressively tighter trust policies:

# Terraform: Per-environment roles
locals {
  environments = {
    dev = {
      branch    = "*"
      condition = "StringLike"
    }
    staging = {
      branch    = "refs/heads/main"
      condition = "StringEquals"
    }
    production = {
      branch    = "refs/heads/main"
      condition = "StringEquals"
    }
  }
}

resource "aws_iam_role" "deploy" {
  for_each = local.environments
  name     = "github-actions-deploy-${each.key}"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Federated = aws_iam_openid_connect_provider.github_actions.arn
        }
        Action = "sts:AssumeRoleWithWebIdentity"
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          }
          (each.value.condition) = {
            "token.actions.githubusercontent.com:sub" = "repo:my-org/my-repo:environment:${each.key}"
          }
        }
      }
    ]
  })
}

In GitHub Actions, use environments to automatically scope the OIDC token:

jobs:
  deploy-prod:
    runs-on: ubuntu-latest
    environment: production  # This changes the OIDC subject claim
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy-production
          aws-region: us-east-1

Kubernetes Workload Identity for Deploy Steps

If your CI pipeline deploys to Kubernetes, you can chain identities: the CI runner authenticates to the cluster using OIDC, and pods in the cluster use Kubernetes workload identity to access cloud resources:

# GKE Workload Identity: Annotate the Kubernetes service account
apiVersion: v1
kind: ServiceAccount
metadata:
  name: app-deploy-sa
  namespace: production
  annotations:
    iam.gke.io/gcp-service-account: app-sa@my-project.iam.gserviceaccount.com

Cross-Account Access Patterns

For organizations with multiple AWS accounts, you can chain role assumptions. The CI runner assumes a role in a central “CI” account via OIDC, then assumes roles in target accounts:

# Step 1: Assume the hub role via OIDC
- uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::HUB_ACCOUNT:role/github-actions-hub
    aws-region: us-east-1

# Step 2: Assume a role in the target account
- uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::TARGET_ACCOUNT:role/deploy-role
    aws-region: us-east-1
    role-chaining: true

Combining OIDC with Terraform

Terraform deployments benefit enormously from OIDC because they typically require broad infrastructure permissions. Configure the AWS provider to use the OIDC-assumed role:

# terraform/providers.tf
provider "aws" {
  region = "us-east-1"

  # No credentials configured here — they come from
  # the environment variables set by the OIDC step
  default_tags {
    tags = {
      ManagedBy   = "terraform"
      Repository  = "my-org/my-infra"
      Environment = var.environment
    }
  }
}

# terraform/backend.tf
terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "infra/terraform.tfstate"
    region         = "us-east-1"
    # State backend also uses the OIDC credentials
    # No access_key or secret_key needed
  }
}

Security Considerations

Workload identity federation is a massive improvement over long-lived credentials, but it is not foolproof. Here are the risks to be aware of and mitigate.

Overly Broad Trust Policies

The most common mistake is configuring trust policies that are too permissive. Examples of dangerous configurations:

  • Trusting an entire organization: repo:my-org/* means any repository in the org can assume the role. A compromised or malicious repo gets production access.
  • Trusting all branches: repo:my-org/my-repo:* means a feature branch with untested code can access production credentials.
  • Missing audience restriction: Without checking the aud claim, tokens intended for one service could be replayed against another.

Always follow the principle of least privilege: restrict to the specific repo, branch, and environment needed.

Token Audience Misconfiguration

The audience (aud) claim specifies the intended recipient of the token. If your trust policy does not validate the audience, an attacker who obtains a token intended for a different service could use it to assume your IAM role. Always validate the audience claim in your trust policies.

OIDC Provider Compromise

If the CI platform’s OIDC provider is compromised (e.g., an attacker can forge JWTs), all trust relationships built on that provider are compromised. Mitigations include:

  • Monitor the CI platform’s security advisories
  • Use additional claim restrictions beyond just the subject
  • Implement IP-based restrictions where supported
  • Set short session durations (15 minutes instead of 1 hour)
  • Enable CloudTrail, GCP Audit Logs, or Azure Activity Logs to detect anomalous federated access

Monitoring and Auditing

Federated access should be monitored like any other authentication. Set up alerts for:

  • Role assumptions from unexpected repositories or branches
  • Unusual access patterns (e.g., production role assumed at 3 AM when no deployment is scheduled)
  • Failed role assumption attempts (may indicate an attacker probing trust policies)
  • Changes to OIDC provider configurations or trust policies
# AWS CloudTrail filter for OIDC role assumptions
{
  "eventName": "AssumeRoleWithWebIdentity",
  "requestParameters": {
    "roleArn": "arn:aws:iam::123456789012:role/github-actions-deploy-production"
  }
}

Fallback Strategies

OIDC federation depends on external services (CI platform’s OIDC endpoint, cloud STS). Plan for outages:

  • Maintain one set of long-lived “break-glass” credentials in a secure vault (e.g., AWS Secrets Manager, 1Password), accessible only to senior engineers
  • Document the break-glass procedure: when to use it, who can authorize it, how to audit its use
  • Set alerts when break-glass credentials are used
  • Rotate break-glass credentials after every use

Migration Guide: From Long-Lived to Short-Lived Credentials

Migrating from long-lived credentials to workload identity federation is a high-value, low-risk change when done systematically. Here is a phased approach.

Phase 1: Inventory

Start by cataloging every secret in your CI/CD platform:

# GitHub: List all secrets for a repository
gh secret list --repo my-org/my-repo

# GitHub: List organization secrets
gh secret list --org my-org

# GitLab: List project variables
curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  "https://gitlab.com/api/v4/projects/$PROJECT_ID/variables"

For each secret, document:

  • What it is (AWS key, GCP key, database password, API token)
  • What it accesses (which cloud account, which service)
  • Which pipelines use it
  • When it was last rotated
  • Whether it can be replaced with OIDC federation

Phase 2: Identify Candidates

Good candidates for OIDC replacement:

  • AWS IAM access keys used in CI/CD
  • GCP service account JSON keys
  • Azure service principal client secrets
  • Any credential used to authenticate to a cloud provider that supports OIDC

Not candidates for OIDC (require different approaches):

  • Database passwords (use Vault dynamic credentials instead)
  • Third-party API keys (use Vault or a secrets manager)
  • SSH keys for Git operations (use deploy keys or GitHub App tokens)
  • Container registry passwords (use cloud-native registry auth via OIDC)

Phase 3: Phased Rollout

  1. Start with non-production: Set up OIDC federation for a single non-production pipeline. Validate it works reliably over a week.
  2. Expand to more non-production pipelines: Convert remaining dev and staging pipelines. Build confidence and documentation.
  3. Production pilot: Pick one production pipeline with good monitoring. Run OIDC alongside the existing credential for a week (the pipeline uses OIDC, but the old credential still exists as a fallback).
  4. Full production rollout: Convert remaining production pipelines. Keep old credentials as break-glass only.

Phase 4: Decommission Old Credentials

After OIDC is working reliably:

  1. Disable, don’t delete: Disable the old credential first. Wait two weeks to ensure nothing breaks.
  2. Monitor for usage: Check CloudTrail, GCP Audit Logs, or equivalent for any usage of the old credential.
  3. Delete: Once you’re confident the credential is unused, delete it.
  4. Remove from CI secrets: Delete the secret from your CI platform’s secret store.
# AWS: Deactivate an old access key
aws iam update-access-key \
  --user-name ci-deploy \
  --access-key-id AKIAIOSFODNN7EXAMPLE \
  --status Inactive

# After validation period, delete it
aws iam delete-access-key \
  --user-name ci-deploy \
  --access-key-id AKIAIOSFODNN7EXAMPLE

Validation

Confirm the migration is complete:

  • All CI/CD pipelines authenticate via OIDC (check workflow logs)
  • No long-lived cloud credentials remain in CI secret stores (except documented break-glass credentials)
  • IAM users or service accounts previously used by CI are deleted or have no access keys
  • Monitoring and alerting are in place for federated access
  • Break-glass procedures are documented and tested

Conclusion

Workload identity federation is the single highest-impact change most teams can make to their CI/CD security posture. It eliminates the most common attack vector — long-lived credentials — and replaces it with a system that is more secure by default: short-lived, automatically scoped, auditable, and resistant to lateral movement.

The migration path is straightforward. Start with one pipeline and one cloud provider. Set up the OIDC trust relationship, update the workflow to use federated auth, validate it works, and move on to the next pipeline. Within a few weeks, you can eliminate every long-lived cloud credential from your CI/CD platform.

The tools are mature and well-supported. GitHub Actions, GitLab CI, and all three major cloud providers have production-ready OIDC federation support. The official GitHub Actions (aws-actions/configure-aws-credentials, google-github-actions/auth, azure/login) handle the token exchange automatically. The Terraform resources exist for infrastructure-as-code setup.

There is no reason to keep long-lived credentials in your CI/CD pipelines. The risk is high, the migration cost is low, and the security improvement is immediate. Start today.