Secrets Management & Secure CI/CD: Keeping Credentials Out of Your Repo
Security16 min read

Secrets Management & Secure CI/CD: Keeping Credentials Out of Your Repo

Hardcoded credentials are one of the most common causes of real-world breaches. Learn how to keep secrets out of Git, manage them with environment variables and secret stores, rotate leaked keys, scan commits, and harden your CI/CD pipeline end to end.

Taha Kocal

Taha Kocal

Full-Stack & Mobile Developer

Jul 24, 2026
#Security#CI/CD#DevOps#Secrets Management#GitHub Actions#Docker#OIDC

Secrets management is the practice of keeping credentials — API keys, database passwords, tokens, and signing keys — out of source code and inside systems designed to protect them, such as environment variables, CI/CD secret stores, and dedicated managers like Vault or AWS Secrets Manager. It matters because exposed secrets in repositories are consistently among the most common root causes of real breaches: automated scanners crawl GitHub continuously, and a key pushed to a public repo can be found and abused within minutes. Effective secrets management combines four layers: prevention with .gitignore and .env.example conventions, secure storage with masked CI variables or a secret manager, detection with scanners like gitleaks running in pre-commit hooks and CI, and response — rotating a leaked credential first, then purging Git history with git-filter-repo. This guide walks through each layer, plus OIDC short-lived tokens, Docker secrets, and pipeline hardening.

Almost every developer has done it at least once: pasted a database password into a config file, committed a service-account JSON to get a deploy working, or dropped an API key into the code "just for now" to unblock a demo. It works, the feature ships, and everyone moves on. The problem is that "just for now" often becomes "forever," and a single leaked credential is one of the most reliable ways to turn a minor mistake into a full-blown breach.

Year after year, exposed secrets in source code and public repositories rank among the most common root causes of real security incidents. Automated scanners crawl GitHub, GitLab, npm, and Docker Hub continuously, and a secret pushed to a public repo can be found and abused within minutes. This guide walks through how to keep credentials out of your repository, manage them safely, respond when one leaks, and harden the CI/CD pipeline that touches all of them.

Why are hardcoded secrets so dangerous?

The core issue is not that developers are careless. It is that secrets in code are frictionless to create and extremely expensive to fully remove. A credential that lives in your codebase inherits every weakness of the codebase: it is copied to every clone, pushed to every fork, cached by every CI runner, and preserved in every backup. The most damaging real-world examples usually fall into a few recurring categories.

Common leaked-secret categories that cause breaches:

  • Cloud service-account JSON keys (GCP, AWS access keys) committed to enable a deploy — these often grant broad access to storage, databases, or the entire project.
  • SMTP and email-provider passwords, which attackers use to send phishing and spam from a trusted domain.
  • Third-party API keys (payment providers, maps, AI APIs) that rack up real charges or leak customer data.
  • Database connection strings with embedded usernames and passwords.
  • Signing keys, JWT secrets, and OAuth client secrets that let an attacker forge tokens or impersonate your app.
  • Private keys and TLS certificates that undermine the trust of your entire infrastructure.

A secret is not "removed" when you delete the line of code. It is removed when you rotate the credential so the old value no longer works.

Why is .gitignore not enough?

A frequent misconception is that adding a file to .gitignore protects it. It does not — .gitignore only prevents Git from tracking files that are not already tracked. If a secret has ever been committed, it lives permanently in your Git history. Anyone with access to the repository can check out an old commit and read it, even after you "delete" it in a later commit. Worse, once it is pushed to a shared remote, forks, mirrors, and CI caches may retain it independently of your local cleanup.

This is why the golden rule is prevention: the safest secret is one that was never committed in the first place. Everything else in this article builds on that principle.

How should you use environment variables and .env files?

The foundational pattern is to separate configuration from code. Instead of hardcoding values, read them from environment variables at runtime. Locally, developers keep those values in a .env file that is never committed; in production, they come from the platform or a secret store. This is the essence of the Twelve-Factor App "config in the environment" principle.

bash
# .env  (LOCAL ONLY — never commit this file)
DATABASE_URL=postgres://app_user:s3cr3t@localhost:5432/app
STRIPE_SECRET_KEY=sk_live_51Hxxxxxxxxxxxxxxxxxxxx
SMTP_PASSWORD=super-secret-mail-password
JWT_SECRET=8f2b1c9d4e6a7f0b3c5d8e1a2b4c6d9f

The single most important line in your repository is the .gitignore entry that keeps .env out of version control. Pair it with a committed .env.example that documents which variables are required — but only contains placeholder values, never real ones. New teammates copy it to .env and fill in their own values.

bash
# .gitignore
.env
.env.local
.env.*.local
*.pem
*-service-account.json
bash
# .env.example  (COMMITTED — placeholders only, no real secrets)
DATABASE_URL=postgres://user:password@localhost:5432/app
STRIPE_SECRET_KEY=sk_test_your_key_here
SMTP_PASSWORD=changeme
JWT_SECRET=generate_a_long_random_value

Loading env vars safely

In Node.js, load environment variables early and validate them at startup so a missing secret fails loudly instead of silently breaking in production. Validating with a schema (for example, zod) turns a vague runtime crash into a clear "STRIPE_SECRET_KEY is required" message.

json
// package.json — load .env only in development
{
  "scripts": {
    "dev": "node --env-file=.env src/index.js",
    "start": "node src/index.js"
  }
}

Where should secrets actually live?

Environment variables solve the "not in code" problem, but in production the values still have to come from somewhere trustworthy. That is the job of a secret store — a system designed to hold sensitive values with access control, auditing, encryption at rest, and rotation support.

CI/CD platform secrets

For most teams, the CI/CD platform is the first and simplest secret store. GitHub Actions provides encrypted repository and environment secrets that are injected as environment variables and automatically masked in logs. GitLab CI offers masked and protected CI/CD variables — "masked" hides the value in job output, and "protected" restricts it to protected branches and tags so a random feature branch cannot exfiltrate production credentials.

yaml
# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production   # secrets scoped to this environment
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        env:
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
        run: ./scripts/deploy.sh

A subtle but important detail: never echo a secret to logs, and never pass it on a command line where it can appear in process listings or shell history. GitHub masks known secret values, but if you transform a secret (base64-decode it, slice it), the transformed value may not be masked. Treat log output as public.

Dedicated secret managers

As systems grow, dedicated secret managers offer stronger guarantees: fine-grained policies, dynamic (short-lived) credentials, detailed audit trails, and centralized rotation. HashiCorp Vault, AWS Secrets Manager, Google Secret Manager, and Azure Key Vault are the common choices. The application authenticates to the manager at runtime and fetches secrets on demand, so the values never sit in a repo or a static config file.

bash
# Fetch a secret at runtime from a cloud secret manager
export DATABASE_URL="$(gcloud secrets versions access latest \
  --secret=database-url)"

# AWS equivalent
export DATABASE_URL="$(aws secretsmanager get-secret-value \
  --secret-id prod/database-url \
  --query SecretString --output text)"

Mobile builds: EAS Secrets

Mobile CI has the same problem. For Expo and React Native projects, EAS Secrets store sensitive values on Expo servers and inject them into the build environment, so API keys never live in app.json or the repo. Remember that anything bundled into a client app can be extracted by a determined user — EAS Secrets protect build-time configuration and CI credentials, not values you embed in the shipped binary. Truly sensitive operations still belong on a backend.

bash
# Store a secret for EAS builds (not committed anywhere)
eas secret:create --scope project \
  --name SENTRY_AUTH_TOKEN \
  --value "your-token-here"

# List and delete
eas secret:list
eas secret:delete --id <secret-id>

What should you do when a secret leaks?

Discovering a committed secret triggers panic and the wrong instinct: rewrite Git history immediately to "hide" the leak. That is backwards. The moment a secret hits a shared remote, assume it is compromised. Purging history reduces future exposure, but it does nothing about the copies attackers, forks, and caches may already have. The correct order is: rotate first, then clean up.

Incident response order for a leaked secret:

  • Rotate the credential immediately — generate a new key/password and revoke the old one at the provider so the leaked value stops working.
  • Audit for abuse — check provider logs, billing, and access records for unauthorized use during the exposure window.
  • Purge the secret from Git history so it stops spreading to new clones.
  • Force-push the rewritten history and have collaborators re-clone.
  • Add scanning and guardrails so it cannot happen again.

Purging with git filter-repo or BFG

The old advice to use git filter-branch is slow and error-prone. The modern tools are git-filter-repo (recommended by the Git project) and the BFG Repo-Cleaner. Both rewrite every commit to remove the offending file or string across the entire history.

bash
# Option A: git-filter-repo — remove a file from ALL history
pip install git-filter-repo
git filter-repo --path config/service-account.json --invert-paths

# Replace a specific leaked string everywhere with ***REMOVED***
echo 'sk_live_51Hxxxxxxxxxxxxxxxxxxxx==>***REMOVED***' > replacements.txt
git filter-repo --replace-text replacements.txt

# Option B: BFG Repo-Cleaner — often simpler for files/patterns
bfg --delete-files service-account.json
bfg --replace-text replacements.txt

# After either tool: expire reflogs, gc, then force-push
git reflog expire --expire=now --all
git gc --prune=now --aggressive
git push --force --all
git push --force --tags

Force-pushing rewritten history is disruptive: every collaborator must re-clone or carefully rebase, and open pull requests may break. That friction is exactly why prevention beats cleanup. And to repeat the point that matters most — history rewriting is worthless if you skipped rotation. The provider still honors the old key until you revoke it.

How do you catch secrets before they are committed?

The cheapest place to stop a secret is before it is ever committed. Secret scanners like gitleaks and trufflehog detect high-entropy strings and known credential patterns. Run them in two layers: a pre-commit hook on developer machines (fast feedback, blocks the commit) and a CI job (the safety net that cannot be skipped with --no-verify).

yaml
# .pre-commit-config.yaml — runs gitleaks before every commit
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.4
    hooks:
      - id: gitleaks
bash
# Install and enable the hook locally
pip install pre-commit
pre-commit install

# Scan the entire history on demand
gitleaks detect --source . --verbose

# trufflehog: scan a repo and verify secrets are live
trufflehog git file://. --only-verified
yaml
# .github/workflows/secret-scan.yml — CI safety net
name: Secret Scan
on: [push, pull_request]

jobs:
  gitleaks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # full history so past commits are scanned
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Also enable your platform's native protection where available — GitHub Push Protection can reject a push that contains a recognized secret before it ever lands on the remote, and GitHub Secret Scanning alerts on known provider patterns. These complement, rather than replace, your own scanning.

How does OIDC eliminate long-lived CI secrets?

The best long-lived secret is no long-lived secret. Every static credential is a liability because it works until someone revokes it. Two principles dramatically reduce the blast radius: least privilege (a credential can do only what it must) and short-lived tokens (credentials that expire in minutes).

Modern CI can eliminate stored cloud keys entirely using OpenID Connect (OIDC). Instead of saving a long-lived AWS/GCP key as a secret, the CI provider mints a signed identity token for the job, and the cloud exchanges it for a short-lived credential scoped to a specific role. No secret is stored, and the token expires when the job ends.

yaml
# GitHub Actions -> AWS via OIDC (no stored access keys)
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write   # required to request the OIDC token
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-deploy
          aws-region: eu-central-1
      - run: aws s3 sync ./dist s3://my-bucket   # short-lived creds

When you do need a static service account, scope it tightly: grant only the specific permissions and resources required, prefer per-environment accounts over one god-account, and set expirations where the provider supports them. A deploy key that can only write to one bucket is far less dangerous than a project-owner key.

Securing the pipeline itself

Your CI/CD pipeline has access to every secret you feed it, so the pipeline is part of your attack surface. A compromised third-party action or an unreviewed workflow change can exfiltrate everything. Harden it deliberately.

Pipeline hardening essentials:

  • Pin third-party actions to a full commit SHA, not a mutable tag — uses: actions/checkout@<sha> instead of @v4, so a hijacked tag cannot silently change what runs.
  • Protect branches: require pull requests, code review, and passing checks before merging to main; disallow force-pushes to protected branches.
  • Set least-privilege GITHUB_TOKEN permissions (permissions: contents: read) and grant more only where a job needs it.
  • Restrict who can approve deployments to production environments and require manual approval for sensitive stages.
  • Avoid running untrusted code (for example, workflows from forked PRs) with access to secrets — use pull_request instead of pull_request_target for untrusted input.
  • Verify artifact and supply-chain integrity: lockfiles, dependency pinning, checksum/signature verification, and generating build provenance (SLSA / signed attestations).
yaml
# Least-privilege token + SHA-pinned action
permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      # Pinned to an immutable commit, with a comment noting the version
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

Build-time vs runtime secrets

Not all secrets are the same, and confusing the two causes leaks. A build-time secret is needed only while building an artifact — a private package registry token, or a source-map upload token. A runtime secret is needed while the application runs — a database password or a payment key. The critical rule: a build-time secret must never end up baked into the final artifact, and a runtime secret should be provided by the environment at launch, not compiled in.

This distinction is especially sharp for frontend apps. Any value bundled into client-side JavaScript is public — anyone can open dev tools and read it. "Public" keys (like a publishable Stripe key or a restricted maps key) are fine there; anything secret must stay on a server.

Docker: never bake secrets into images

Docker images are a notorious source of leaks because each layer is immutable and shippable. Using ARG or ENV or COPY to bring a secret into an image embeds it in a layer forever — even if a later layer "removes" the file, the earlier layer still contains it, and anyone who pulls the image can extract it.

dockerfile
# BAD — secret is permanently embedded in an image layer
FROM node:20-alpine
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
RUN npm ci          # token now lives in the image history forever

The correct approach for build-time secrets is BuildKit secret mounts, which expose the secret only to a single RUN step and never write it to any layer. For runtime secrets, inject them when the container starts, via environment variables from your orchestrator or Docker/Swarm secrets mounted as files under /run/secrets.

dockerfile
# GOOD — BuildKit secret mount, never persisted in a layer
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci
COPY . .
bash
# Build with the secret supplied out-of-band (not in the image)
DOCKER_BUILDKIT=1 docker build \
  --secret id=npmrc,src=$HOME/.npmrc \
  -t myapp .

# Runtime secrets: inject at launch, don't bake in
docker run --env-file <(cat runtime.env) myapp
# or mount a Docker/Swarm secret at /run/secrets/db_password

The secrets management checklist

Use this as a practical audit for any project. If you can check every box, you have eliminated the most common credential-leak paths.

Prevention and configuration:

  • .env is in .gitignore, and a committed .env.example documents required variables with placeholders only.
  • No secrets are hardcoded in source, config files, or client-side bundles.
  • Environment variables are validated at startup so missing secrets fail fast.
  • Build-time and runtime secrets are clearly separated; runtime secrets are injected by the environment.

Storage and access:

  • Production secrets live in a secret store (CI masked/protected variables, Vault, or a cloud secret manager), not in files.
  • Mobile CI uses EAS Secrets or equivalent; nothing secret is embedded in the shipped binary.
  • Service accounts follow least privilege, scoped per environment.
  • Cloud access from CI uses OIDC short-lived tokens instead of stored long-lived keys where possible.

Detection and response:

  • gitleaks or trufflehog runs as a pre-commit hook and as a CI job scanning full history.
  • Platform push protection and secret scanning alerts are enabled.
  • There is a documented incident runbook: rotate first, audit for abuse, then purge history with git-filter-repo or BFG.
  • Secrets are rotated on a schedule, not only after an incident.

Pipeline and supply chain:

  • Third-party actions are pinned to commit SHAs.
  • Protected branches require review and passing checks; force-push is disabled on main.
  • GITHUB_TOKEN and job permissions are least-privilege.
  • Untrusted (forked) workflows never run with access to production secrets.
  • Dependencies are pinned via lockfiles and integrity/provenance is verified; Docker images never contain baked-in secrets.

Secrets management is not a one-time setup — it is a habit. The teams that avoid credential breaches are the ones that made "not in the repo" the path of least resistance.

Keeping credentials out of your repository comes down to a few durable principles: separate config from code, store secrets in systems built to protect them, prefer short-lived and least-privilege access over static keys, scan continuously, and treat any leak as a rotation event first and a cleanup event second. Build these into your defaults — the .gitignore, the .env.example, the pre-commit hook, the OIDC role, the hardened workflow — and secure handling stops being a chore and becomes simply how your projects work.

Share this article

Related Articles