Hardcoded credentials in source code remain one of the most consistently exploited attack vectors, yet it keeps happening. Not because engineers don’t know better, but because the path of least resistance in most CI/CD setups still leads directly to an .env file or a hardcoded string in a workflow YAML.
This post is about fixing that — practically, not just in principle.
Why Secrets Leak
Secrets end up in source code for predictable reasons:
- CI/CD setup is done quickly under deadline pressure
- The “right way” requires more upfront configuration than the wrong way
- Secrets are copied from one place to another without rotation
- Development and production configs are treated the same
The problem compounds over time. A secret committed six months ago may still be valid and actively scanned for by automated tools running against public (and private) repositories right now.
The Threat Model
Before choosing tooling, understand what you’re defending against:
- Leakage via source control — secrets committed to git, including in branches and history
- Leakage via CI logs — secrets echoed or printed during pipeline execution
- Leakage via artifacts — Docker images, compiled binaries, or build caches that contain secrets
- Unauthorized access — legitimate secrets exposed to the wrong pipelines, jobs, or people
These require different controls. Scanning helps with #1. Masking handles #2. Build hygiene covers #3. Secrets management platforms address #4.
Scanning First: Find What’s Already There
Before you can fix anything, you need to know your current exposure. Two tools worth running immediately:
Gitleaks — scans git history, staged changes, and the working tree for secrets using regex patterns and entropy analysis.
# Scan full repo history
gitleaks detect --source . --log-opts="--all"
# Pre-commit hook usage
gitleaks protect --staged
TruffleHog — goes deeper, scanning git history with verified detection against live APIs.
trufflehog git file://. --only-verified
Run both as part of onboarding and in CI. A pre-commit hook with Gitleaks is low-friction and catches the majority of accidental commits before they happen.
Platform-Native Secrets: The Baseline
Every major CI/CD platform has a secrets store. Use it.
GitHub Actions:
jobs:
deploy:
steps:
- name: Deploy
env:
API_KEY: ${{ secrets.API_KEY }}
run: ./deploy.sh
GitLab CI:
deploy:
variables:
API_KEY: ${{ secrets.API_KEY }}
script:
- ./deploy.sh
Key rules for platform-native secrets:
- Scope secrets to the minimum required environment (don’t expose production secrets to PRs from forks)
- Enable secret masking in logs
- Audit who has access to modify secrets
- Rotate regularly and after any personnel changes
Going Further: HashiCorp Vault
For organizations that need audit trails, dynamic secrets, fine-grained access policies, and rotation automation, a dedicated secrets manager is the right answer. HashiCorp Vault is the most widely used option.
The key concept in Vault is dynamic secrets — instead of long-lived credentials, Vault generates a short-lived credential on demand for each pipeline run.
# Pipeline retrieves a temporary database credential
vault read database/creds/my-role
# Returns: username, password, lease_duration (e.g. 1h)
The credential expires automatically. There’s nothing to rotate manually. If the pipeline is compromised, the attacker has a credential that’s already expiring.
Vault integrates natively with GitHub Actions via the hashicorp/vault-action:
- uses: hashicorp/vault-action@v2
with:
url: https://vault.example.com
method: jwt
role: ci-deploy
secrets: |
secret/data/prod/db password | DB_PASSWORD
SOPS for Configuration Files
For secrets that need to live in files (Kubernetes manifests, Helm values, config files), SOPS encrypts individual values within YAML/JSON files while leaving the structure readable.
# values.yaml — encrypted with SOPS, safe to commit
db_password: ENC[AES256_GCM,data:abc123...,type:str]
db_host: postgres.internal # plaintext — fine to commit
SOPS integrates with AWS KMS, GCP KMS, Azure Key Vault, and age keys, so the encryption key stays out of the repository entirely.
What Good Looks Like
A mature secrets setup in CI/CD has these properties:
- No long-lived credentials — dynamic secrets where possible, short TTLs everywhere else
- Least privilege — each pipeline job can only access the secrets it needs
- Auditability — every secret access is logged
- Automated detection — Gitleaks or equivalent runs on every commit
- Rotation policy — secrets are rotated on a schedule and immediately on any suspected exposure
The goal isn’t a perfect system on day one. It’s moving consistently away from the dangerous defaults — and the biggest win is usually just getting scanning in the pre-commit hook and moving secrets out of .env files committed to repos.