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:

# 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:

{
  "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:

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:

- 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:

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:

- 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:

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:

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.
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.