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).
- 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.
- Token exchange: The pipeline presents this JWT to the cloud provider’s Security Token Service (STS) and requests temporary credentials.
- 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.
- 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) orgitlab.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
mainbranch - Environment: Only accept tokens from the
productionenvironment - 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 branchrepo:my-org/my-repo:environment:production— Only the production environmentrepo:my-org/my-repo:pull_request— Only pull request workflowsrepo:my-org/my-repo:ref:refs/tags/v*— Only version tags (useStringLikein 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_idandnamespace_path— The group or user namespaceproject_idandproject_path— The specific projectpipeline_source— How the pipeline was triggered (push, merge_request, schedule, etc.)environment— The deployment environment, if setref_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
audclaim, 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
- Start with non-production: Set up OIDC federation for a single non-production pipeline. Validate it works reliably over a week.
- Expand to more non-production pipelines: Convert remaining dev and staging pipelines. Build confidence and documentation.
- 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).
- Full production rollout: Convert remaining production pipelines. Keep old credentials as break-glass only.
Phase 4: Decommission Old Credentials
After OIDC is working reliably:
- Disable, don’t delete: Disable the old credential first. Wait two weeks to ensure nothing breaks.
- Monitor for usage: Check CloudTrail, GCP Audit Logs, or equivalent for any usage of the old credential.
- Delete: Once you’re confident the credential is unused, delete it.
- 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.