---
title: "Secrets Management in CI/CD Pipelines: OIDC, Vault Integration, and Credential Hygiene"
description: "Practical guide to secrets management across CI/CD pipelines — GitHub Actions secrets vs OIDC federation, HashiCorp Vault integration, short-lived credentials, secret rotation, environment-scoped secrets, and strategies to avoid secret sprawl."
url: https://agent-zone.ai/knowledge/cicd/cicd-secrets-management/
section: knowledge
date: 0001-01-01
categories: ["cicd"]
tags: ["secrets","oidc","vault","credentials","security","github-actions","gitlab-ci","secret-rotation","workload-identity"]
skills: null
tools: ["vault","github-actions","gitlab-ci","aws-iam","gcp-workload-identity"]
levels: ["intermediate","advanced"]
word_count: 1205
formats:
  json: https://agent-zone.ai/knowledge/cicd/cicd-secrets-management/index.json
  html: https://agent-zone.ai/knowledge/cicd/cicd-secrets-management/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Secrets+Management+in+CI%2FCD+Pipelines%3A+OIDC%2C+Vault+Integration%2C+and+Credential+Hygiene
---


# Secrets Management in CI/CD Pipelines

Every CI/CD pipeline needs credentials: registry tokens, cloud provider keys, database passwords, API keys for third-party services. How you store, deliver, and scope those credentials determines whether a single compromised pipeline job can escalate into a full infrastructure breach. The difference between a mature and an immature pipeline is rarely in the build steps -- it is in the secrets management.

## The Problem with Static Secrets

The default approach on every CI platform is storing secrets as encrypted variables: GitHub Actions secrets, GitLab CI variables, Jenkins credentials store. These work but create compounding risks:

- **No expiration.** A secret set two years ago is still valid. Nobody remembers what it accesses.
- **Broad scope.** A repository-level secret is available to every workflow and every branch. A contributor opening a PR against a branch with `pull_request_target` could potentially access secrets meant only for production deployments.
- **No audit trail.** You know a secret exists but not when it was last used, by which workflow, or whether it is still needed.
- **Rotation is manual.** Changing a secret means updating it in the CI platform, in every environment that uses it, and in every team that knows about it.

Static secrets are acceptable for low-risk, low-frequency use cases like a Slack webhook URL. For anything touching infrastructure or production data, use short-lived credentials.

## OIDC Federation: Eliminating Static Cloud Credentials

OIDC (OpenID Connect) federation replaces static cloud provider credentials with short-lived tokens issued on demand. Your CI platform acts as an identity provider, and your cloud provider trusts it through a configured federation.

### GitHub Actions to AWS

Configure the trust relationship once on the AWS side:

```bash
# Create the OIDC provider in AWS
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
```

Create an IAM role with a trust policy scoped to your specific repository and branch:

```json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::123456789012: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:ref:refs/heads/main"
      }
    }
  }]
}
```

The `sub` claim condition is critical. Without it, any GitHub repository could assume this role. Use `ref:refs/heads/main` for production deploy roles, or `pull_request` for limited read-only roles used in PR pipelines.

In the workflow:

```yaml
permissions:
  id-token: write
  contents: read

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

The `role-session-name` creates distinct CloudTrail entries per workflow run, giving you a full audit trail of what each CI run accessed.

### GitHub Actions to GCP

GCP uses Workload Identity Federation:

```yaml
- uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: projects/123456/locations/global/workloadIdentityPools/github/providers/github-actions
    service_account: ci-deploy@myproject.iam.gserviceaccount.com
```

The GCP side requires creating a Workload Identity Pool, adding GitHub as a provider, and binding the pool to a service account with an attribute condition restricting the repository.

### GitLab CI OIDC

GitLab supports the same pattern. The CI job requests a token via the `id_tokens` keyword:

```yaml
deploy:
  id_tokens:
    AWS_TOKEN:
      aud: https://sts.amazonaws.com
  script:
    - >
      STS_CREDS=$(aws sts assume-role-with-web-identity
      --role-arn arn:aws:iam::123456789012:role/gitlab-deploy
      --role-session-name gitlab-$CI_PIPELINE_ID
      --web-identity-token $AWS_TOKEN)
    - export AWS_ACCESS_KEY_ID=$(echo $STS_CREDS | jq -r '.Credentials.AccessKeyId')
    - export AWS_SECRET_ACCESS_KEY=$(echo $STS_CREDS | jq -r '.Credentials.SecretAccessKey')
    - export AWS_SESSION_TOKEN=$(echo $STS_CREDS | jq -r '.Credentials.SessionToken')
```

## HashiCorp Vault Integration

OIDC covers cloud providers, but many pipelines need secrets that live outside cloud IAM: database passwords, third-party API keys, TLS certificates. Vault centralizes these secrets and provides dynamic, short-lived credentials.

### Vault with GitHub Actions

Vault can authenticate GitHub Actions runners using JWT auth:

```yaml
- name: Retrieve secrets from Vault
  uses: hashicorp/vault-action@v3
  with:
    url: https://vault.example.com
    method: jwt
    role: ci-deploy
    jwtGithubAudience: https://vault.example.com
    secrets: |
      secret/data/myapp/production db_password | DB_PASSWORD ;
      secret/data/myapp/production api_key | API_KEY
```

Configure Vault's JWT auth backend to trust GitHub's OIDC provider:

```bash
vault auth enable jwt

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

vault write auth/jwt/role/ci-deploy \
  role_type="jwt" \
  bound_audiences="https://vault.example.com" \
  bound_claims_type="glob" \
  bound_claims='{"repository":"myorg/myrepo","ref":"refs/heads/main"}' \
  user_claim="repository" \
  policies="ci-deploy" \
  ttl="10m"
```

The `ttl=10m` means the Vault token expires ten minutes after issuance. Even if the token leaks from a CI log, it is useless within minutes.

### Dynamic Database Credentials

Vault's database secrets engine generates credentials on demand with automatic expiration:

```bash
vault secrets enable database
vault write database/config/mydb \
  plugin_name=postgresql-database-plugin \
  connection_url="postgresql://{{username}}:{{password}}@db.example.com:5432/myapp" \
  allowed_roles="ci-readonly" \
  username="vault_admin" \
  password="admin_password"

vault write database/roles/ci-readonly \
  db_name=mydb \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
  default_ttl="30m" \
  max_ttl="1h"
```

Each CI run gets a unique database user that expires automatically. No shared credentials. No cleanup needed.

## Environment-Scoped Secrets

Secrets should be scoped to the narrowest context possible. GitHub Actions provides three scopes:

- **Organization secrets**: Available to all repos in the org. Use sparingly -- a shared Slack token, a monitoring API key.
- **Repository secrets**: Available to all workflows in one repo. Default scope for most secrets.
- **Environment secrets**: Available only to jobs targeting a specific environment. The correct scope for production credentials.

```yaml
jobs:
  deploy-staging:
    environment: staging
    # Only secrets defined in the "staging" environment are available
    steps:
      - run: deploy --db-url "${{ secrets.DATABASE_URL }}"

  deploy-production:
    environment: production
    # Different DATABASE_URL, only available after manual approval
    steps:
      - run: deploy --db-url "${{ secrets.DATABASE_URL }}"
```

Configure the `production` environment to require reviewer approval. This means production secrets are not just scoped -- they are gated behind a human approval step.

## Secret Rotation Strategy

Every static secret needs a rotation schedule. The rotation process must be automated, or it will not happen:

**Cloud credentials**: Eliminated by OIDC. No rotation needed.

**API keys for third-party services**: Store in Vault. Rotate every 90 days. Automate with a scheduled pipeline that generates a new key, updates Vault, verifies the new key works, and revokes the old one.

**Database passwords**: Use Vault dynamic credentials to eliminate rotation entirely. If you must use static passwords, rotate every 30 days.

**Signing keys**: Use keyless signing (Fulcio) to avoid managing signing keys. If key-based signing is required, rotate annually and use HSM-backed storage.

## Avoiding Secret Sprawl

Secret sprawl happens when the same credential exists in multiple places: the CI platform, a developer's `.env` file, a Kubernetes secret, a wiki page. Sprawl makes rotation impossible because you cannot be sure you have updated every copy.

**Centralize in Vault.** Every secret lives in one place. CI pipelines, applications, and developers all read from Vault using their own authentication method (OIDC for CI, Kubernetes auth for pods, LDAP for developers).

**Audit regularly.** List all secrets in your CI platform. For each one, answer: what does it access, who created it, when was it last rotated, is it still needed? Delete anything you cannot answer confidently.

**Use short-lived credentials everywhere possible.** A credential that expires in 15 minutes cannot sprawl. OIDC tokens, Vault dynamic secrets, and STS session tokens all self-destruct.

## Common Mistakes

1. **Using a single cloud credential for all environments.** The production deploy key should not be the same key used to run PR tests. Scope credentials to the minimum access required per pipeline stage.
2. **Printing secrets in CI logs for debugging.** Most CI platforms mask known secrets, but derived values (base64-encoded, URL-encoded) are not masked. Never echo secrets, even temporarily.
3. **Storing secrets in Terraform state.** Terraform state files contain the plaintext values of any `sensitive` variable. Encrypt state at rest and restrict access to state storage.
4. **Skipping OIDC because "secrets work fine."** Static credentials work until they leak. OIDC is a one-time setup that permanently eliminates an entire class of security incidents.

