Stop Hardcoding Secrets in Your CI/CD Pipeline
Stop Hardcoding Secrets in Your CI/CD Pipeline
Your CI/CD pipeline has more access than most of your developers. It can deploy to production, push to registries, and access your most sensitive infrastructure.
It's also where secrets go to die.
The CI/CD Secrets Problem
Most teams manage CI/CD secrets as an afterthought. The typical pattern:
- Developer needs a secret in CI
- They add it to the platform's secrets UI
- They copy-paste from wherever they found it (Slack, 1Password, a
.envfile) - Six months later, nobody remembers where the canonical value lives
Now multiply that by every secret, every pipeline, every environment. You end up with:
- Secrets scattered across platforms: GitHub Actions has some, GitLab CI has others, your deployment scripts have more
- No single source of truth: Is the production Stripe key in GitHub Secrets or the deploy script?
- Stale credentials: The API key was rotated three months ago, but three pipelines still have the old one
- No audit trail: Who added that AWS key? When? Which pipelines use it?
This isn't theoretical. CircleCI's 2023 breach exposed customer secrets because attackers compromised their secrets storage. If your secrets are spread across multiple platforms, your attack surface is every platform.
The Three CI/CD Secrets Mistakes
Mistake 1: Secrets in Repository Files
The most obvious anti-pattern—and still common:
# Don't do this
deploy:
script:
- docker login -u admin -p $uper$ecretPassword123
Even "hidden" in environment files or config:
# Also bad: .github/workflows/deploy.yml
env:
DATABASE_URL: postgres://user:[email protected]/app
Git history is forever. Anyone with repo access can find these.
Mistake 2: Secrets Printed to Logs
CI platforms mask secrets they know about, but they can't mask what they don't know:
# This will print your secret in plain text
- name: Debug
run: |
echo "Deploying with config:"
cat .env
env | sort
Even worse—error messages that include secrets:
Error: Authentication failed for postgres://admin:Pr0dP@[email protected]
That's now in your CI logs, accessible to anyone with repo access, possibly retained for months.
Mistake 3: Over-Privileged Secrets
Most teams use one set of credentials for everything:
# Same AWS key for linting, testing, and production deploy
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET }}
If your test job only needs to read from S3, why does it have credentials that can delete your production database?
The Better Approach
Principle 1: Single Source of Truth
Every secret should live in exactly one place. Your CI/CD platforms should pull from that source, not store their own copies.
# GitHub Actions
steps:
- name: Pull secrets
env:
KEYENV_TOKEN: ${{ secrets.KEYENV_TOKEN }}
run: keyenv pull -e production
The only secret GitHub needs is a service token. All actual secrets come from KeyEnv at build time. When you rotate a credential, you rotate it once—every pipeline gets the new value automatically.
Principle 2: Environment Isolation
Production secrets shouldn't exist in your staging pipeline. Create separate service tokens per environment:
# staging.yml
- name: Pull staging secrets
env:
KEYENV_TOKEN: ${{ secrets.KEYENV_TOKEN_STAGING }}
run: keyenv pull -e staging
# production.yml
- name: Pull production secrets
env:
KEYENV_TOKEN: ${{ secrets.KEYENV_TOKEN_PRODUCTION }}
run: keyenv pull -e production
Now a compromised staging pipeline can't access production credentials.
Principle 3: Runtime Injection Over File Storage
Don't write secrets to disk if you don't have to:
# Writes .env to disk (necessary sometimes, but riskier)
- run: keyenv pull -e production
# Injects into environment, never touches disk
- run: keyenv run -e production -- npm run deploy
keyenv run injects secrets as environment variables for the child process only. They never exist as files, reducing the attack surface.
Principle 4: Scan Before It's Too Late
Catch hardcoded secrets before they reach your default branch:
name: Security
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Scan for secrets
uses: keyenv/scan-action@v1
with:
severity: high
KeyEnv's scanner detects 149+ secret patterns—AWS keys, Stripe tokens, database URLs, private keys, and more. A failing scan blocks the PR before secrets hit your history.
A Complete Example
Here's a production-ready GitHub Actions workflow:
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Scan for hardcoded secrets
uses: keyenv/scan-action@v1
- name: Install KeyEnv
run: curl -fsSL https://keyenv.dev/install.sh | bash
- name: Run tests with staging secrets
env:
KEYENV_TOKEN: ${{ secrets.KEYENV_TOKEN_STAGING }}
run: keyenv run -e staging -- npm test
deploy:
needs: test
runs-on: ubuntu-latest
environment: production # Requires approval
steps:
- uses: actions/checkout@v4
- name: Install KeyEnv
run: curl -fsSL https://keyenv.dev/install.sh | bash
- name: Deploy with production secrets
env:
KEYENV_TOKEN: ${{ secrets.KEYENV_TOKEN_PRODUCTION }}
run: keyenv run -e production -- ./deploy.sh
What this gets you:
- Secret scanning catches leaks before merge
- Staging isolation keeps test jobs away from production
- Runtime injection means no secrets on disk
- Environment protection requires approval for production
- Single source of truth means rotating secrets is one action, not ten
What About GitLab/CircleCI/Others?
The pattern works everywhere. Here's GitLab:
stages:
- test
- deploy
test:
stage: test
script:
- curl -fsSL https://keyenv.dev/install.sh | bash
- export PATH="$HOME/.keyenv/bin:$PATH"
- keyenv scan --severity high
- keyenv run -e staging -- npm test
variables:
KEYENV_TOKEN: $KEYENV_TOKEN_STAGING
deploy:
stage: deploy
script:
- curl -fsSL https://keyenv.dev/install.sh | bash
- export PATH="$HOME/.keyenv/bin:$PATH"
- keyenv run -e production -- ./deploy.sh
variables:
KEYENV_TOKEN: $KEYENV_TOKEN_PRODUCTION
only:
- main
when: manual
CircleCI with orbs:
version: 2.1
orbs:
keyenv: keyenv/[email protected]
jobs:
test:
docker:
- image: cimg/node:20.0
steps:
- checkout
- keyenv/scan
- keyenv/load:
environment: staging
- run: npm test
deploy:
docker:
- image: cimg/node:20.0
steps:
- checkout
- keyenv/load:
environment: production
- run: ./deploy.sh
workflows:
build-and-deploy:
jobs:
- test
- deploy:
requires: [test]
filters:
branches:
only: main
The Migration Path
You don't have to fix everything at once. Here's a practical order:
Week 1: Centralize secrets
- Create a KeyEnv project
- Import your existing secrets
- Create service tokens for each CI platform
Week 2: Update pipelines
- Replace hardcoded secrets with
keyenv pull - Keep platform secrets as backup during transition
Week 3: Add scanning
- Add
keyenv scanto PR checks - Fix any existing hardcoded secrets it finds
Week 4: Clean up
- Remove secrets from CI platform UIs
- Delete any
.envfiles from repos - Document the new workflow for your team
The Bottom Line
CI/CD pipelines are high-value targets. They have broad access, run frequently, and often have weaker security than production systems.
The fix isn't complicated:
- One source of truth for all secrets
- Environment isolation so staging can't touch production
- Runtime injection so secrets don't hit disk
- Automated scanning so hardcoded secrets never merge
Your pipeline shouldn't be your weakest link.
curl -fsSL https://keyenv.dev/install.sh | sh
keyenv init
Set it up once, and stop worrying about which platform has which secret.