Secrets Management — Environment Variables, Secret Managers, and Rotation

Learn where credentials and keys should live in a PHP application (environment variables and dedicated secret managers, never source code), how to structure .env files so they're never committed, why and how secrets get rotated, and how secret-scanning tools catch credentials that slip into git history before they cause a breach.

Every application has secrets: database passwords, API keys for third-party services, encryption keys, OAuth client secrets, webhook signing keys. Where those secrets live — and how carefully that's controlled — is one of the biggest practical determinants of whether an application gets breached through carelessness rather than a sophisticated attack. The single most common real-world security incident in this category isn't a clever exploit; it's a developer committing a database password straight into a public (or eventually-public) git repository. This lesson covers the discipline that prevents that: environment variables as the baseline mechanism, dedicated secret managers for anything beyond a small team's needs, a rotation strategy so a leaked secret has a limited blast radius, and secret-scanning tools that catch mistakes before or after they reach git history.

Environment variables: the baseline mechanism

The standard approach in modern PHP (and virtually every language) is to keep configuration that varies by environment — and especially anything secret — out of the codebase entirely and supply it through environment variables at runtime. In local development, a package like vlucas/phpdotenv (which frameworks like Laravel and Symfony use under the hood) reads a .env file in the project root and loads its key=value pairs into the environment before the application boots, so code reads getenv('DB_PASSWORD') or a framework's config() helper rather than a literal string.

The .env file itself must never be committed to version control — it holds the actual secret values for whatever environment it belongs to. The pattern every PHP project should follow is: commit a .env.example (or .env.dist) file with the same keys but placeholder or empty values, so new developers know what variables they need to supply, and add .env to .gitignore before the first commit that would include it — not after. In production, .env files are often avoided entirely in favor of environment variables injected directly by the hosting platform (a container orchestrator, a PaaS's dashboard, a systemd unit's Environment= directives), which avoids ever having a plaintext secrets file sitting on a production disk at all.

• .env holds real secret values for one environment and is never committed — it must be listed in .gitignore from the very first commit. • .env.example (committed) documents which variables are needed, with placeholder values only. • Production commonly injects environment variables directly from the hosting platform, skipping a literal .env file on disk entirely. • Application code reads getenv()/$_ENV or a framework's config() helper — never a hardcoded literal.

Dedicated secret managers: beyond environment variables

Environment variables are a fine baseline, but they have real limits: they're usually visible to anything else running in the same process or container, they don't have built-in audit logging of who read which secret and when, and rotating one means redeploying or restarting every process that reads it. As a team and its infrastructure grow, dedicated secret managers — HashiCorp Vault, AWS Secrets Manager, Google Secret Manager, Azure Key Vault — become the better tool: the application authenticates to the secret manager at startup (often via a short-lived identity token rather than a static credential) and fetches secrets over an authenticated, audited API call, rather than having them sitting in plaintext in the environment at all.

What a secret manager adds on top of plain environment variables: centralized access control (which service or team can read which secret), a full audit trail (who/what accessed a secret and when), automatic or coordinated rotation (some managers can rotate a database password and push the new value to every consumer automatically), and encryption at rest for the secret store itself. A PHP application typically talks to one of these through a small SDK or HTTP client during bootstrap, caching the fetched values in memory for the life of the request or process rather than persisting them to disk. You won't build a secret manager yourself — the skill here is recognizing when environment variables alone are no longer enough for your team's scale and threat model, and knowing what a managed one buys you.

• HashiCorp Vault, AWS Secrets Manager, Google Secret Manager, Azure Key Vault — centralized, audited, access-controlled secret stores. • Adds: fine-grained access control, audit logging, coordinated/automatic rotation, encryption at rest. • The app authenticates with a short-lived identity/token, then fetches secrets over an API call at startup rather than reading a static file. • Worth adopting once a team outgrows 'everyone has the same .env values' — multiple services, multiple environments, compliance requirements.

Rotation strategy: limiting the blast radius of a leak

A secret that's never rotated is a secret whose exposure — through a leaked laptop, a departing employee, a misconfigured log, a compromised dependency — grants an attacker access indefinitely. Rotation means periodically replacing a secret's value and invalidating the old one, on a schedule for routine hygiene (e.g. database passwords every 90 days) and immediately, out of schedule, the moment any leak is suspected or confirmed. The goal isn't to make secrets impossible to steal — it's to bound how long a stolen secret remains useful.

Practical rotation needs a plan for the transition window, not just 'change the value': for a database password, that typically means creating a new credential, updating every consumer to use it, confirming they've all picked it up, and only then revoking the old one — revoking first would cause an outage. For signing/encryption keys where old data was signed or encrypted under the previous key, key versioning (tagging which key version produced a given value) lets verification/decryption still work against old data while new operations use the current key. Secret managers with built-in rotation support (like AWS Secrets Manager's automatic rotation for RDS credentials) automate exactly this handoff.

• Routine rotation: on a schedule, as hygiene, even with no known incident. • Emergency rotation: immediately, the moment a leak is suspected — don't wait for confirmation. • Plan the transition (new credential live before old one is revoked) to avoid an outage during rotation. • Key-versioning lets old data stay verifiable/decryptable after a key rotates, instead of breaking historical records.

Secret scanning: catching leaks in git history

Even with good discipline, secrets slip into git commits — a debug line with a real API key left in, a .env file committed once before .gitignore caught up, a config file pasted with a live credential. Secret-scanning tools scan a repository's history (not just its current state) for patterns that look like credentials — AWS access key formats, private key headers, database connection strings with embedded passwords — and flag them. GitHub's built-in secret scanning does this automatically on public repositories (and on private ones with the feature enabled) and can even partner directly with some providers to auto-revoke a leaked key the moment it's detected. Standalone tools like gitleaks and truffleHog do the same scanning locally or in CI, and are commonly wired into a pre-commit hook (catching a secret before it's ever committed) and into CI (catching one that slipped past the hook, on every push).

The critical fact about a leaked secret in git history is that deleting the file in a later commit does not remove it — the secret is still readable in the repository's history by anyone who can clone it, forever, unless the history itself is rewritten (git filter-repo or BFG Repo-Cleaner) and force-pushed, which is disruptive and doesn't help if the repo was ever public or forked. The only fully reliable response to a leaked secret is to treat it as compromised and rotate it immediately — rewriting history is a good idea for hygiene, but rotation is the step that actually closes the exposure.

• GitHub secret scanning, gitleaks, truffleHog — scan commit history (not just current files) for credential-shaped patterns. • Wire scanning into pre-commit hooks (catch it before commit) and CI (catch what slips past the hook). • Deleting a leaked secret's file in a new commit does NOT remove it from history — it's still recoverable by anyone who can clone the repo. • The only reliable fix for a leaked secret is rotating it immediately — treat it as compromised the instant it's committed, public repo or not.

Loading secrets from the environment, never from source

$dotenv->required([...]) makes the app fail loudly at boot if a needed secret is missing, instead of failing confusingly later. Never fall back to a hardcoded default value for a secret (e.g. ?? 'dev-password') — that default tends to survive into production by accident. In production, prefer secrets injected by the platform/orchestrator over shipping a .env file to the server at all.

// .env (NEVER committed — listed in .gitignore)
// DB_PASSWORD=s3cr3t-real-value
// STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxx

// .env.example (committed — placeholders only, documents what's required)
// DB_PASSWORD=
// STRIPE_SECRET_KEY=

// bootstrap.php
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
$dotenv->required(['DB_PASSWORD', 'STRIPE_SECRET_KEY']); // fail fast if missing

// application code — read from the environment, never a literal
$dbPassword = $_ENV['DB_PASSWORD'] ?? throw new RuntimeException('DB_PASSWORD not set');
$stripeKey = getenv('STRIPE_SECRET_KEY') ?: throw new RuntimeException('STRIPE_SECRET_KEY not set');

Code Examples

This pattern — validate all required secrets exist at boot, throw immediately if any are missing — turns a missing-secret bug from a confusing runtime failure deep in a request into an obvious, loud startup failure, and it never contains a hardcoded fallback value that could accidentally ship to production.

if ($missing !== []) { throw new RuntimeException(...); } Common mistake: Using $_ENV[$key] ?? 'some-default' for a secret means a missing production secret silently falls back to a dev value instead of failing loudly.

Adding .env to .gitignore only prevents future commits from including it — if .env was ever committed even once before this line existed, it's already in the repository's history and must be treated as leaked (rotate every secret it contained) regardless of whether it's later removed from the working tree. Private key files (*.pem, *.key) and any project-specific secrets file (config/secrets.php) belong in .gitignore alongside .env — the rule is 'anything with a live credential in it,' not just the one conventional filename.

declare(strict_types=1);

final class Config
{
    private array $values;

    public function __construct(array $required)
    {
        $missing = [];
        $values = [];

        foreach ($required as $key) {
            $value = $_ENV[$key] ?? getenv($key);
            if ($value === false || $value === '') {
                $missing[] = $key;
                continue;
            }
            $values[$key] = $value;
        }

        if ($missing !== []) {
            throw new RuntimeException(
                'Missing required environment variables: ' . implode(', ', $missing)
            );
        }

        $this->values = $values;
    }

    public function get(string $key): string
    {
        return $this->values[$key];
    }
}

// At boot: crashes immediately and clearly if a secret wasn't provided,
// instead of limping along and failing confusingly mid-request later.
$config = new Config(['DB_PASSWORD', 'STRIPE_SECRET_KEY', 'APP_ENCRYPTION_KEY']);
echo 'Config loaded OK' . PHP_EOL;
# .gitignore — must exist BEFORE the first `git add .`, not added after a leak
.env
.env.local
.env.*.local
*.pem
*.key
config/secrets.php

Where a secret should — and shouldn't — live

Secrets move right as an application matures — never left, back into source code.

Source code (git)         .env file (gitignored)        Secret manager / platform env
  ----------------------    --------------------------    -------------------------------
  NEVER a real secret        Real secrets, local/dev only   Real secrets, staging/production
  Committed, shared, public   Never committed, per-machine   Centralized, audited, rotated
  .env.example placeholders   Loaded at boot via phpdotenv   Injected by platform or fetched via API

Important Rules

Never commit a real secret value to version control — not in a config file, not in a comment, not in a test fixture.

Add .env (and any secrets file) to .gitignore before the first commit that could include it, not after.

Commit a .env.example with placeholder values so the required configuration is documented without exposing real secrets.

Fail loudly at application boot if a required secret is missing — never silently fall back to a hardcoded default.

Treat any secret that ever touched git history as compromised and rotate it immediately, even if the commit was later removed.

How this plays out on a real team

A small team typically starts with .env files per developer and per environment, managed through their deployment platform's environment-variable settings (Heroku config vars, a Docker Compose env_file, Kubernetes secrets). As the team grows past a handful of services, they migrate to a secret manager so that rotating a database password doesn't mean manually updating a dozen deployment configs, and so a security review can answer 'who can read the payment provider's API key' with an actual audit log instead of 'everyone with repo access.' Meanwhile, GitHub's secret scanning (or gitleaks in CI) runs on every push as a safety net for the mistake every team eventually makes at least once — someone committing a real credential by accident.

git add.env && git commit -m "add local config" #.env was never in.gitignore echo '.env' >>.gitignore # before ever running git add, then commit only.env.example

Once .env is committed, its contents are in the repository's history permanently, readable by anyone who can clone it, even after a later commit deletes the file — the only real fix at that point is rotating every secret the file contained, not just removing it going forward.

$stripeKey = $_ENV['STRIPE_SECRET_KEY'] ?? 'sk_test_default_fallback_key';

$stripeKey = $_ENV['STRIPE_SECRET_KEY'] ?? throw new RuntimeException('STRIPE_SECRET_KEY not set');

A fallback default for a secret is a trap: it hides a misconfiguration in development, and the same fallback can silently ship into a production environment where the real variable was accidentally left unset, running production traffic against a wrong or shared key.

What is the correct place for real secret values in a PHP project's repository? A. In a config.php file committed alongside the rest of the source code, for convenience. B. In a .env file that is listed in .gitignore and never committed — only a .env.example with placeholders is committed. C. In a comment above the function that uses them, so future developers can find them quickly. D. In the composer.json 'extra' section.

B. In a .env file that is listed in .gitignore and never committed — only a .env.example with placeholders is committed. — .env holds the real values and must never be committed; .env.example documents the required keys with placeholder values so it's safe to commit.

Why do dedicated secret managers (Vault, AWS Secrets Manager, etc.) offer more than plain environment variables? A. They make secrets load faster at application boot. B. They add centralized access control, audit logging, and coordinated rotation that plain environment variables don't provide. C. They eliminate the need to ever change a secret's value. D. They automatically encrypt all of an application's source code.

B. They add centralized access control, audit logging, and coordinated rotation that plain environment variables don't provide. — Environment variables are a fine baseline but lack built-in audit trails, fine-grained access control, and coordinated rotation across every consumer — which is exactly what a dedicated secret manager adds.

A database password was accidentally committed to a public GitHub repository and deleted in a later commit. What is the correct next step? A. Nothing further is needed — deleting the file in a later commit removes the exposure. B. Wait to see if anyone actually uses the leaked password before taking action. C. Rotate the password immediately, treating it as compromised, regardless of whether misuse is confirmed. D. Rename the environment variable so old code stops referencing it.

C. Rotate the password immediately, treating it as compromised, regardless of whether misuse is confirmed. — A secret is still fully readable in git history after being deleted in a later commit, by anyone who can clone the repository — the only reliable response is to rotate it immediately and treat it as compromised the moment it was committed.

What do secret-scanning tools like gitleaks or GitHub secret scanning primarily do? A. Encrypt secrets automatically before they are committed. B. Scan a repository's commit history and pushes for patterns that look like credentials, and flag or block them. C. Generate new random secrets for every deployment. D. Replace hardcoded secrets in source code with environment variable references automatically.

B. Scan a repository's commit history and pushes for patterns that look like credentials, and flag or block them. — Secret-scanning tools detect credential-shaped patterns (API key formats, private key headers, connection strings) in commit history and new pushes, flagging them — often before they ever reach a public repository — so the leak can be caught and the secret rotated quickly.

Write a small function loadRequiredEnv(array $keys): array that reads each key from $_ENV, throws a RuntimeException listing ALL missing keys (not just the first one found) if any are absent or empty, and otherwise returns an associative array of key => value. Then describe, in a comment, the two separate actions you'd take (beyond deleting the file) if you discovered a .env containing a live database password had been committed and pushed six months ago.

function loadRequiredEnv(array $keys): array { $missing = []; $result = []; foreach ($keys as $key) { $value = $_ENV[$key] ?? ''; if ($value === '') { $missing[] = $key; continue; } $result[$key] = $value; } if ($missing !== []) { throw new RuntimeException('Missing: ' . implode(', ', $missing)); } return $result; } // Response actions: (1) rotate the database password immediately (treat it as // compromised regardless of whether anyone is known to have seen it), and // (2) rewrite git history to remove the file (git filter-repo/BFG) and force-push, // understanding that rotation — not history rewriting — is what actually closes the exposure.

Collect missing keys into an array as you loop, and only throw after the loop finishes checking every key, so the error message is complete in one shot.