Stop Storing Credentials in Your Pipeline Configuration Files
by Eric Hanson, Backend Developer at Clean Systems Consulting
How Credentials End Up in Configuration Files
It starts small. A developer is setting up a new pipeline step that needs a database connection for integration tests. The fastest path to working is putting the credentials directly in the workflow YAML:
# This appears in a real CI file more often than it should
- name: Run integration tests
env:
DB_URL: jdbc:postgresql://test-db.internal:5432/testdb
DB_USER: ci_user
DB_PASSWORD: hunter2secretpassword123
run: ./gradlew integrationTest
The developer ships the feature, the tests run, and the credential is now in version control. If the repository is public, it's already been harvested by automated secret scanners. If it's private, it's in git history permanently — accessible to every current and future team member, and to anyone who gains read access to the repository.
More commonly, the credential is for a test environment with limited blast radius. But the pattern established here — "credentials go in the YAML" — propagates. The next developer uses the same pattern for a less-limited environment.
Why This Is Worse Than It Looks for "Test-Only" Credentials
Git history is forever. Removing the credential from the file does not remove it from git history. Anyone with access to the repository can run git log -p and find deleted credentials. Rotating the credential after detection is required; removing it from the YAML is not sufficient.
"Test-only" credentials often aren't. Test databases frequently contain sanitized copies of production data. Test service accounts often have overly broad permissions ("we'll tighten it later"). The blast radius of a "test-only" credential is often larger than assumed.
Private repos become public. Repositories get transferred, open-sourced, or accidentally made public. The private-repo assumption is not a permanent security property.
Third parties read your repository. GitHub Apps, bots, dependency scanners, and contractors all get read access to your private repository over time. Every read-access grant expands the set of entities that can access your hardcoded credentials.
The Correct Replacements
Platform secret stores are the first-line replacement. GitHub Actions Secrets, GitLab CI/CD Variables, and Bitbucket Pipelines Variables store credentials encrypted at rest, inject them as environment variables at runtime, and mask them in log output. Zero code required beyond the YAML reference:
- name: Run integration tests
env:
DB_URL: ${{ secrets.TEST_DB_URL }}
DB_USER: ${{ secrets.TEST_DB_USER }}
DB_PASSWORD: ${{ secrets.TEST_DB_PASSWORD }}
run: ./gradlew integrationTest
The credential is in the platform's secret store, not in the repository. Rotation requires updating the secret in one place; the YAML doesn't change.
Environment-specific secret managers are the right approach for credentials that vary by environment. AWS Secrets Manager, HashiCorp Vault, and GCP Secret Manager store credentials centrally, provide audit logs of every access, support automatic rotation, and can be accessed from any environment without storing values in configuration files:
- name: Fetch test credentials
run: |
export DB_PASSWORD=$(aws secretsmanager get-secret-value \
--secret-id /myapp/test/db-password \
--query SecretString \
--output text)
echo "DB_PASSWORD=$DB_PASSWORD" >> $GITHUB_ENV
# Note: GitHub Actions automatically masks values set in GITHUB_ENV
# if the value was previously stored as a secret
Dynamic credentials via Vault or AWS IAM remove the stored credential entirely. Vault can issue a database credential that's valid for 1 hour, specifically for the integration test run, and revoke it automatically when the TTL expires. No rotation required — each pipeline run gets a fresh credential:
- name: Get dynamic DB credentials
uses: hashicorp/vault-action@v3
with:
url: https://vault.internal
method: jwt
role: ci-integration-tests
secrets: |
database/creds/test-db username | DB_USER ;
database/creds/test-db password | DB_PASSWORD
Scanning for Existing Exposures
Before adopting better practices, find what's already exposed. TruffleHog scans git history (not just current file state) for credential patterns:
# Scan entire git history for secrets
docker run --rm \
-v "$(pwd):/pwd" \
trufflesecurity/trufflehog:latest \
git file:///pwd --since-commit HEAD~100 --only-verified
The --only-verified flag reduces false positives by testing whether detected credentials are still active. Any verified finding requires immediate credential rotation followed by git history rewriting (via git filter-repo) if the repository is public.
Add TruffleHog or detect-secrets as a pre-commit hook so future commits are scanned before they reach the repository:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
The baseline file records known-safe false positives so they don't block legitimate commits.
The One Rule
If a value is a credential — password, API key, token, certificate private key — it does not belong in a file that is committed to version control. Not as a placeholder, not "temporarily," not in a test environment YAML that "isn't really secret." The rule has no exceptions that are worth their risk.