← All posts

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:

  1. Developer needs a secret in CI
  2. They add it to the platform's secrets UI
  3. They copy-paste from wherever they found it (Slack, 1Password, a .env file)
  4. 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 scan to PR checks
  • Fix any existing hardcoded secrets it finds

Week 4: Clean up

  • Remove secrets from CI platform UIs
  • Delete any .env files 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.