“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:
| Tier | Mechanism | Best for |
|---|---|---|
| 1 | OIDC (no credential at all) | AWS, GCP, Azure - use whenever possible |
| 2 | Short-lived tokens fetched at runtime | API tokens with rotation, Vault leases |
| 3 | GitHub Secrets / env vars | Non-rotatable credentials, third-party tokens |
| 4 | Repository 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 type | Rotation trigger | Rotation method |
|---|---|---|
| AWS IAM keys | Quarterly OR any suspicious activity | aws iam create-access-key → update secret → aws iam delete-access-key |
| Database passwords | Annually OR on personnel change | Secrets Manager rotation lambda |
| API tokens (third-party) | On token exposure | Regenerate in provider dashboard, update GitHub Secret |
| Service account passwords | On personnel change | Automated 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: writepermission) - 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
permissionsat workflow level, grant per-job - Review org-level secrets - are they still needed?
- Run
gitleaksagainst 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.