← All posts
· 5 min read ·
CI/CDSecuritySecrets ManagementDevSecOpsAWSGitHub Actions

Secrets in CI/CD: Why Environment Variables Aren't Enough

GitHub Secrets and environment variables are convenient but have real limitations - they can be printed, inherited by subprocesses, and live forever. Here's a tiered approach to secrets management in CI/CD pipelines.

Vault icon with CI/CD pipeline arrows

“Just put it in GitHub Secrets” is the default advice for CI/CD credentials, and for many use cases it’s fine. But environment variables have a threat model most teams never think through: they’re accessible to every process in the runner, they can be accidentally echoed in logs, they persist for the lifetime of the job, and they’re often scoped too broadly. Understanding the alternatives helps you choose the right mechanism for each secret.

The Environment Variable Threat Model

When you set a secret in GitHub Secrets and expose it as an environment variable, it’s available to:

  • Every run: step in the job
  • Every action called by the job
  • Any subshell spawned from those steps
  • Any process those subshells fork

GitHub masks the value in logs, but masking isn’t the same as protection. If your secret appears in structured output, gets embedded in a JSON response, or is split across multiple log lines, masking may fail:

# This gets masked:
echo "$MY_SECRET"

# This might not get masked (secret split by jq output):
echo '{"key":"'"$MY_SECRET"'"}' | jq .

# This definitely doesn't get masked (base64 encoded):
echo "$MY_SECRET" | base64

The practical risk is low for most applications, but it’s real.

A Tiered Approach

Match the secret mechanism to the sensitivity and lifetime of the credential:

TierMechanismBest for
1OIDC (no credential at all)AWS, GCP, Azure - use whenever possible
2Short-lived tokens fetched at runtimeAPI tokens with rotation, Vault leases
3GitHub Secrets / env varsNon-rotatable credentials, third-party tokens
4Repository variables (non-secret)Non-sensitive config, feature flags

Tier 1 is always preferable. If the target system supports OIDC (AWS, GCP, Azure, HashiCorp Vault, npm, PyPI), use it. No credential to store, no credential to rotate, no credential to leak.

Fetching Short-Lived Tokens at Runtime

For secrets that need to exist as strings but can be short-lived, fetch them from Secrets Manager within the job rather than storing them in GitHub Secrets.

AWS Secrets Manager:

steps:
    - name: Configure AWS credentials (OIDC)
      uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v4
      with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1

    - name: Fetch secrets from Secrets Manager
      run: |
          DB_PASSWORD=$(aws secretsmanager get-secret-value \
              --secret-id prod/db/password \
              --query SecretString \
              --output text)
          echo "::add-mask::$DB_PASSWORD"
          echo "DB_PASSWORD=$DB_PASSWORD" >> "$GITHUB_ENV"

    - name: Run migrations
      run: npm run db:migrate
      env:
          DATABASE_URL: postgres://app:${{ env.DB_PASSWORD }}@db:5432/prod

echo "::add-mask::$value" registers the value with GitHub’s log masker at runtime, so it’s masked even for values not stored in GitHub Secrets.

The OIDC role has permission to fetch specific secrets by ARN. The actual database password never enters GitHub’s secret storage.

HashiCorp Vault

If you use Vault, the official GitHub Action handles the OIDC-to-Vault auth flow:

- name: Import secrets from Vault
  uses: hashicorp/vault-action@d1720f055e0635fd932a1d2a48f87a666a57906c # v3.0.0
  with:
      url: https://vault.example.com
      method: jwt
      role: github-actions
      secrets: |
          secret/data/prod/api API_KEY | API_KEY ;
          secret/data/prod/db PASSWORD | DB_PASSWORD

The Vault JWT auth method accepts GitHub’s OIDC token directly. Configure Vault’s JWT auth backend with GitHub’s JWKS URL and a bound claims policy that restricts access by repository and branch.

Scoping Secrets to Environments

GitHub Environments allow you to scope secrets to specific deployment contexts with protection rules:

jobs:
    deploy-production:
        environment: production  # Requires reviewer approval
        runs-on: ubuntu-latest
        steps:
            - name: Deploy
              run: ./deploy.sh
              env:
                  PROD_API_KEY: ${{ secrets.PROD_API_KEY }}  # Only available in 'production' environment

Environment-scoped secrets:

  • Are only available to jobs targeting that environment
  • Can require reviewer approval before the job runs
  • Can be locked to specific branches
  • Are audited separately from repository secrets

This means PROD_API_KEY cannot be accessed by a PR workflow - only a deployment to the production environment after a human approves.

Detecting Secret Sprawl

Before you can improve your secrets posture, you need to know what’s out there:

# List all repository secrets (requires admin access)
gh secret list

# List environment secrets
gh secret list --env production

# List org-level secrets
gh secret list --org your-org

# Scan git history for accidentally committed secrets (install gitleaks first)
gitleaks detect --source . --verbose

# Or using GitHub's built-in secret scanning
gh api repos/org/repo/secret-scanning/alerts | jq '.[].secret_type'

GitHub’s secret scanning is free for public repositories and included in GitHub Advanced Security for private repositories. It scans every push for over 200 known secret patterns (AWS keys, GitHub tokens, Stripe keys, etc.) and alerts you immediately if a match is found.

Secret Rotation Policy

Define rotation requirements before you need them:

Secret typeRotation triggerRotation method
AWS IAM keysQuarterly OR any suspicious activityaws iam create-access-key → update secret → aws iam delete-access-key
Database passwordsAnnually OR on personnel changeSecrets Manager rotation lambda
API tokens (third-party)On token exposureRegenerate in provider dashboard, update GitHub Secret
Service account passwordsOn personnel changeAutomated via IdP

For AWS: if you’ve migrated to OIDC, there are no IAM access keys to rotate. This is the strongest possible position.

The Practical Checklist

For any CI/CD pipeline handling production credentials:

  • Replace AWS access keys with OIDC (id-token: write permission)
  • Replace GCP service account keys with Workload Identity Federation
  • Scope remaining GitHub Secrets to environments with protection rules
  • Add ::add-mask:: for any secrets fetched at runtime
  • Enable GitHub secret scanning on all repositories
  • Set minimum permissions at workflow level, grant per-job
  • Review org-level secrets - are they still needed?
  • Run gitleaks against git history at least once

The goal is not zero stored secrets - third-party tokens will always need to live somewhere. The goal is that every stored secret has a known scope, a rotation schedule, and the narrowest access possible.

← All posts