Secrets Management: Keeping Credentials Out of Code
AI generated
OWASP
0x00
Security · Secrets Management · DevOps · Compliance
Secrets Management: Keeping Credentials Out of Code
Why deleted secrets in Git are never really gone

Storing database passwords or API keys directly in code creates a risk that persists even after deletion, because Git keeps every version permanently. This article shows how Magento teams keep credentials out of code, using env.php patterns, environment variables, dedicated secrets managers like Vault, consistent rotation and automated leak scanning.

16 min read env.php · Vault · AWS Secrets Manager Git History · Rotation · Compliance

1. Why hardcoded secrets are a persistent risk

A secret is any value that grants access to a system, a database or a third-party service: a database password, an API token, a JWT signing key, the crypt key from app/etc/env.php, an OAuth client secret, or the credentials stored in auth.json for repo.magento.com. As soon as such a value lives directly in source code, whether as a constant, a default parameter or a test fixture, it becomes part of the version history and therefore visible to everyone with repository access: current team members, former employees with a local clone, external contractors, CI systems and, in many cases, automated backups of the repository as well.

The risk grows with the reach of the repository. A private repo with three developers has a manageable attack surface, a repository wired into CI/CD with several forks and external contractor access does not. For Magento shops, a compliance dimension adds to that: anyone processing personal data or payment information is subject to GDPR and, for card data, PCI DSS requirements that explicitly govern how credentials are handled. A leaked database password is especially critical because it grants direct, unfiltered access to the entire database, with no application layer and its own authorization logic in the way.

2. Git history: why deleted secrets are not really gone

A common misconception: removing a secret from a file and pushing the commit removes it from the repository. In reality the old blob content remains fully intact in .git/objects as long as any commit, branch, tag or reflog entry still references it. Anyone who clones the repository, or who already had an older checkout, can use git log -p or git show to reach the old commit and read the plaintext value, regardless of how many new commits have been made since.

Actually removing a secret from history requires a history rewrite, typically with the BFG Repo-Cleaner or git filter-repo. Both tools scan every commit, replace the affected content and write new commit hashes. That breaks every existing clone: a plain git pull no longer works afterward, and everyone involved has to re-clone or manually rebase their local branches. Important: the history rewrite is a downstream cleanup step. The first and most important action after a leak is always rotating the affected secret, because the rewrite never reaches old copies sitting in forks, CI caches or local clones anyway.


#!/usr/bin/env bash
# Purge a leaked secret from the entire Git history with git-filter-repo
# WARNING: this rewrites every commit hash, coordinate with the whole team first

# Install: pip install git-filter-repo (or brew install git-filter-repo)

# 1. Rotate the leaked secret FIRST, before touching the history
#    (a history rewrite does not reach forks, CI caches or old local clones)

# 2. Remove a specific file from every commit
git filter-repo --path app/etc/env.php --invert-paths

# 3. Or replace a specific string pattern across all blobs
echo 'DB_PASSWORD_OLD_VALUE==>REDACTED' > replacements.txt
git filter-repo --replace-text replacements.txt

# 4. Force-push the rewritten history (coordinate downtime with the team)
git push origin --force --all
git push origin --force --tags

# 5. Every team member must re-clone or hard-reset their local copy,
#    a normal "git pull" will not work after a history rewrite

3. Magento env.php and environment variable patterns

Magento stores database access, the crypt key, cache backend configuration and session handler settings in app/etc/env.php as a plain PHP array by default. The file is generated by bin/magento setup:install and is already excluded in Magento's default .gitignore, yet in practice it still gets committed by accident, for example when developers override .gitignore locally or force-add the file with git add -f to share a working local configuration with the team.

The more robust pattern replaces hardcoded values in env.php with calls to getenv(), so the actual credentials only ever exist as environment variables on the deployment target, set through Docker Compose, Kubernetes secrets or the CI/CD pipeline, never in the repository. An env.php.dist file with placeholders serves as a template and gets versioned; the real env.php is generated locally or at deploy time from environment variables. This pattern also works for third-party modules that store their own configuration values in env.php, such as payment gateway keys or search index credentials.


<?php
declare(strict_types=1);

/**
 * app/etc/env.php generated from environment variables instead of hardcoded values.
 * The real file stays out of version control (see .gitignore); only
 * env.php.dist with placeholders is committed as a template.
 */
return [
    'db' => [
        'connection' => [
            'default' => [
                'host'     => getenv('MAGE_DB_HOST') ?: 'db',
                'dbname'   => getenv('MAGE_DB_NAME') ?: 'magento',
                'username' => getenv('MAGE_DB_USER') ?: '',
                'password' => getenv('MAGE_DB_PASSWORD') ?: '',
            ],
        ],
    ],
    'crypt' => [
        // Never hardcode the crypt key, it decrypts every encrypted value in the DB
        'key' => getenv('MAGE_CRYPT_KEY') ?: '',
    ],
    'cache' => [
        'frontend' => [
            'default' => [
                'backend' => 'Cm_Cache_Backend_Redis',
                'backend_options' => [
                    'server' => getenv('MAGE_REDIS_HOST') ?: '127.0.0.1',
                    'password' => getenv('MAGE_REDIS_PASSWORD') ?: '',
                ],
            ],
        ],
    ],
];

4. .gitignore discipline for local config files

Magento's default .gitignore already excludes the most sensitive paths: app/etc/env.php, var/, generated/, pub/media/ and pub/static/. What gets overlooked often is composer's auth.json, either in the project root or in ~/.composer/, which holds the credentials for repo.magento.com as well as any private Composer repositories, in plaintext. This file must never enter the repository, not even in a modified or partially redacted form, because Composer tokens grant full read access to commercial Magento packages.

Order matters: .gitignore has to exist before the first commit of a sensitive file, because adding the rule afterward only prevents future commits, not the history that already exists. An additional safety net is a pre-commit hook that actively blocks files like env.php or auth.json, instead of relying purely on individual developer discipline. Teams running multiple environments also benefit from a separate, tightly access-controlled repository for deployment configuration, kept apart from the application code.


# .gitignore additions for a Magento 2 project
# Never commit generated configuration that contains live credentials

/app/etc/env.php
/app/etc/config.php.bak
/auth.json
/.env
/.env.*
!/.env.example

# Generated and cached content also has no place in version control
/var/
/generated/
/pub/static/
/pub/media/*
!/pub/media/.htaccess

# Editor and local override files that sometimes carry test credentials
/.idea/
/*.local.php

5. Dedicated secrets managers: Vault, AWS Secrets Manager & co.

For setups with multiple environments, multiple teams or regulatory requirements, .gitignore discipline alone is not enough. Dedicated secrets managers such as HashiCorp Vault, AWS Secrets Manager, Azure Key Vault or Google Secret Manager centralize credentials with fine-grained access control, a complete audit log of every read access and, in Vault's case, dynamically generated, time-limited database credentials instead of static passwords.

The integration pattern for Magento: at container start or deployment, an entrypoint script fetches the required secrets from the vault and sets them as environment variables, or writes them into an env.php that is never baked into the image or stored in the repository. Vault's dynamic secrets engine for MySQL can even issue a fresh, short-lived database password on every deployment, which is automatically invalidated once the lease expires, with no human having to rotate it manually. The extra setup effort pays off especially for teams with compliance obligations or more than a handful of environments.


# .github/workflows/deploy.yml (excerpt)
# Secrets are fetched at deploy time, never stored in the repository or the image
name: deploy-production

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Fetch secrets from HashiCorp Vault
        uses: hashicorp/vault-action@v3
        with:
          url: https://vault.mironsoft.internal:8200
          method: jwt
          role: magento-production
          secrets: |
            secret/data/magento/production db_password | MAGE_DB_PASSWORD ;
            secret/data/magento/production crypt_key    | MAGE_CRYPT_KEY

      - name: Deploy with env vars injected at runtime
        run: |
          ssh deploy@prod "MAGE_DB_PASSWORD='${MAGE_DB_PASSWORD}' \
            MAGE_CRYPT_KEY='${MAGE_CRYPT_KEY}' \
            bash /srv/magento/deploy.sh"

6. Secret rotation: cadence, automation, ownership

Rotation limits the window in which a compromised secret is usable. A static database password that has not changed in three years carries far more damage potential than one that rotates automatically every 24 hours, even if both eventually leak. In practice a combination works well: rotate critical secrets like DB access and the crypt key automatically through a secrets manager with a short TTL, and rotate less critical values like internal API keys at least quarterly, plus immediately whenever someone with privileged access leaves the team.

Zero-downtime rotation requires a two-stage pattern: first the new secret is activated alongside the old one so both work in parallel, then every consumer is switched over to the new secret, and only after that is the old one deactivated. For Magento payment provider integrations this means creating the new API key as a second valid key in the gateway dashboard first, updating the payment module configuration, and revoking the old key only after successful verification. Skip this pattern and every rotation risks a downtime window.

7. Detecting leaks: scanning tools and pre-commit hooks

Automated secret scanning is the second line of defense next to .gitignore. Tools like gitleaks, trufflehog and git-secrets scan diffs, entire repository histories or CI pipelines for patterns that resemble typical secrets: AWS access keys, private RSA keys, JWT token structures, generic high-entropy strings sitting next to variable names like password or secret. Wired in as a pre-commit hook, gitleaks protect --staged blocks a commit locally before the secret is even pushed, which is the cheapest possible point to prevent a leak.

Pre-commit hooks alone are not enough, because they can be bypassed with git commit --no-verify. A second check in the CI pipeline, scanning every push and periodically the entire history, catches leaks that slipped past the local hook. GitHub and GitLab also offer native secret scanning that recognizes known provider token formats such as AWS or Stripe keys automatically and can proactively notify the respective provider, sometimes even before the team itself reacts.


#!/usr/bin/env bash
# .git/hooks/pre-commit: block commits that contain secrets
# Install gitleaks first: brew install gitleaks (or download from GitHub releases)
set -euo pipefail

if ! command -v gitleaks &> /dev/null; then
  echo "[WARN] gitleaks not installed, skipping secret scan" >&2
  exit 0
fi

# Scan only the staged diff, fails the commit if a likely secret is found
if ! gitleaks protect --staged --verbose; then
  echo "[BLOCKED] Potential secret detected in staged changes." >&2
  echo "Remove the secret, rotate it if it was ever committed before, and retry." >&2
  exit 1
fi

8. Incident response: what to do after a leaked secret

The order of steps after a discovered leak determines the actual damage. First and most urgent step: rotate or revoke the affected secret immediately, not clean up the Git history first. A history rewrite can take days until every clone is updated, while a rotated password renders the secret worthless the moment rotation completes, regardless of how many copies already exist out there.

Second step: assess the blast radius, for example through access logs of the database or the affected service, to identify unusual access during the window between the leak and the rotation. Third step: if personal data was reachable, check whether a GDPR notification obligation within 72 hours to the supervisory authority applies. Fourth step: history rewrite as cleanup, and a post-mortem that fixes the root cause, usually by introducing pre-commit scanning or a secrets manager, so the same mistake does not happen again.

9. Storage approaches compared

Every approach to handling credentials has different consequences for rotation effort, traceability and the damage in a worst-case scenario. The table below contrasts the insecure approach with the recommended one across the dimensions that matter most.

Dimension Insecure: secret in code Recommended: secrets manager Benefit
Rotation Requires a code change and deploy Automatic rotation via TTL No deploy needed for rotation
Audit trail No log of who read it and when Vault/AWS log every access Traceability during incidents
Blast radius on leak Exposed forever in Git history Short-lived, dynamic credentials Minimal window for misuse
Access control Anyone with repo access sees it all Fine-grained policies per role Least privilege enforceable
CI/CD visibility Ends up in build logs or image layers Injected at runtime, never baked in No secret in image or log

Mironsoft

Secrets management, env.php hardening and Vault integration for Magento shops

Ready to finally keep credentials out of your code?

We scan your Git history for leaked secrets, clean it up with git filter-repo, introduce env.php patterns backed by environment variables, and wire Vault or AWS Secrets Manager into your deployment pipeline.

Git history audit

Full repository history scan with gitleaks and trufflehog

Vault integration

HashiCorp Vault or AWS Secrets Manager wired into CI/CD and deployment

Rotation processes

Zero-downtime rotation for database, crypt key and payment API keys

10. Summary

The core problem in secrets management is rarely technical, it is a matter of discipline and getting the order of operations right. Deleting a secret from the code is not enough as long as the Git history still contains it; only a history rewrite with BFG or git filter-repo actually removes it, and even then only after the secret has already been rotated. Magento's env.php should never hold plaintext credentials, it should pull values from the environment via getenv(), backed by a versioned env.php.dist template. .gitignore must exist from day one, not retroactively, and gets reinforced by pre-commit hooks running gitleaks.

For teams running multiple environments or facing compliance obligations, moving to a dedicated secrets manager like Vault or AWS Secrets Manager pays off: it automates rotation, logs every access and can issue dynamic, short-lived credentials. In the event of an actual leak, the order is always the same: rotate first, then check the blast radius through access logs, then evaluate the GDPR notification obligation, and only then clean up the history.

Secrets Management: Keeping Credentials Out of Code, The Key Takeaways

Git history is permanent

Deleting is not enough. Only BFG Repo-Cleaner or git filter-repo actually remove a secret, followed by a force-push and a re-clone for everyone.

env.php pattern

getenv() instead of plaintext, env.php.dist as a versioned template, real values only set at runtime.

Secrets managers

Vault or AWS Secrets Manager for rotation, audit trails and dynamic, short-lived credentials in larger setups.

Incident response

Rotate first, check the blast radius, evaluate the GDPR notification obligation, clean up history last.

11. FAQ: Secrets Management

1What counts as a secret in a Magento development context?
Any value that grants access to a system: a database password, an API token, the crypt key from env.php, an OAuth client secret, or the credentials in composer's auth.json for repo.magento.com.
2Why is deleting a secret from the code not enough?
The old blob content stays in .git/objects as long as any commit, branch or reflog entry references it. Anyone with repository access can read it in plaintext.
3Difference between BFG Repo-Cleaner and git filter-repo?
Both remove sensitive content from the full history. git filter-repo is the officially recommended, more flexible tool, BFG is simpler for basic cases.
4How does env.php.dist protect against accidental leaks?
env.php.dist contains only placeholders and gets versioned, the real env.php stays excluded via .gitignore and is generated from environment variables.
5At what team size does a dedicated secrets manager become worthwhile?
At the latest with multiple environments, multiple teams, or compliance obligations like PCI DSS. Pure .gitignore discipline does not scale reliably beyond that.
6What are dynamic secrets in HashiCorp Vault?
Vault generates a new, time-limited database credential on demand, automatically invalidated once the lease expires, with no manual rotation needed.
7How often should database passwords be rotated?
Critical secrets automatically with a short TTL, less critical values at least quarterly, and immediately whenever someone with privileged access leaves the team.
8What does gitleaks do as a pre-commit hook?
gitleaks protect --staged scans the staged diff before every commit and blocks it locally before a secret can ever be pushed.
9What is the first step after a discovered secret leak?
Immediate rotation or revocation of the secret, not cleaning up the Git history. Rotation takes effect instantly, a history rewrite takes time until every clone is current.
10Does a leaked secret involving personal data trigger a notification obligation?
If personal data was accessible, a GDPR notification obligation within 72 hours to the supervisory authority may apply. Assess this early in incident response.