Secrets and Configuration

Keep credentials out of the repository and the web root: read them from the environment and rotate anything that leaked.

Syntax$dsn = getenv('DB_DSN') ?: throw new RuntimeException('DB_DSN is not set');

Configuration is the part of your code that changes between your laptop and the server: a database address, a mail setting, an API key. A secret is configuration that grants access. The rules differ for the two, and mixing them is how credentials end up in public.

Out of the web root, out of the repository

A config file inside the web root is one server misconfiguration away from being downloaded as plain text. Put it beside the public directory rather than inside it, and require it by absolute path. Then keep secrets out of version control entirely. Committing .env or a filled-in config.php puts the credential in every clone, every CI log and every fork, permanently.

Environment variables

The usual answer is the environment: name-value pairs the operating system, container runtime or process manager sets before PHP starts. getenv('DB_PASSWORD') reads one. Nothing is stored in your files, and each deployment supplies its own values. Commit a .env.example listing the names with empty values, so the next person knows what to set, and list the real .env in .gitignore.

Rotating an exposed credential

Deleting the file does not help. Git keeps history, logs are archived, and anything pushed to a public host should be assumed copied within minutes. Treat exposure as compromise: issue a new credential, deploy it, then revoke the old one, in that order, and check access logs for use you did not make.

Example

Example · php
<?php

declare(strict_types=1);

// A real server sets these before PHP starts. putenv() stands in for that here.
putenv('APP_ENV=production');
putenv('DB_DSN=mysql:host=db;dbname=shop;charset=utf8mb4');
putenv('DB_PASSWORD=s3cret-from-the-vault');

function env(string $name): string
{
    $value = getenv($name);
    if ($value === false || $value === '') {
        throw new RuntimeException("Missing environment variable: $name");
    }

    return $value;
}

function mask(string $secret): string
{
    return str_repeat('*', max(0, strlen($secret) - 4)) . substr($secret, -4);
}

echo 'env: ' . env('APP_ENV') . "\n";
echo 'dsn: ' . env('DB_DSN') . "\n";
echo 'password: ' . mask(env('DB_PASSWORD')) . "\n";

try {
    env('STRIPE_KEY');
} catch (RuntimeException $e) {
    echo 'error: ' . $e->getMessage() . "\n";
}

// Output:
// env: production
// dsn: mysql:host=db;dbname=shop;charset=utf8mb4
// password: *****************ault
// error: Missing environment variable: STRIPE_KEY

When to use it

  • Giving a database password to a production container without putting it in the repository.
  • Running the same code on a laptop, a staging box and production with different credentials.
  • Making a deployment fail loudly at boot when a required setting was not provided.
  • Responding to an API key that was pushed to a public repository.

More examples

A config file that lives outside the web root

A config file that returns an array is read with $config = require '/var/app/config/config.php';. The file itself holds no secrets, only the names of the environment variables that do, so it is safe to commit. throw as an expression on the right of ?: has been valid since PHP 8.0, and it means a missing database password stops the application at boot instead of surfacing as a confusing connection error later.

Example · php
<?php

// /var/app/config/config.php  -- note: NOT under /var/app/public

declare(strict_types=1);

return [
    'env' => getenv('APP_ENV') ?: 'production',
    'debug' => getenv('APP_DEBUG') === '1',
    'db' => [
        'dsn' => getenv('DB_DSN') ?: throw new RuntimeException('DB_DSN is not set'),
        'user' => getenv('DB_USER') ?: 'app',
        'password' => getenv('DB_PASSWORD') ?: throw new RuntimeException('DB_PASSWORD is not set'),
    ],
    'uploads' => '/var/app/storage/uploads',
];

Failing at startup with every missing name at once

Call this once in your bootstrap. Collecting all the missing names before throwing turns four failed deployments into one, which is worth the extra loop the first time someone sets up the project. Never put the value into the message, because exception messages reach logs and error pages.

Example · php
<?php

declare(strict_types=1);

function requireEnv(string ...$names): array
{
    $values = [];
    $missing = [];

    foreach ($names as $name) {
        $value = getenv($name);
        if ($value === false || $value === '') {
            $missing[] = $name;
            continue;
        }
        $values[$name] = $value;
    }

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

    return $values;
}

$config = requireEnv('DB_DSN', 'DB_PASSWORD', 'MAIL_DSN', 'STRIPE_SECRET');

Dealing with a committed .env

The order is the lesson. People reach for history rewriting and treat the leak as closed, but every existing clone, fork and CI cache still carries the value, so only revoking the credential actually ends the exposure. Run this sequence the day you find a secret in a repository, not the day you have time to rewrite history.

Example · bash
# 1. Rotate first. The new credential must exist and work before the
#    old one is revoked, or you take the site down.

# 2. Stop tracking the file, keep it on disk.
git rm --cached .env
echo '.env' >> .gitignore
git commit -m "Stop tracking .env"

# 3. Commit a template so the next person knows the names.
printf 'DB_DSN=\nDB_PASSWORD=\nSTRIPE_SECRET=\n' > .env.example
git add .env.example && git commit -m "Add .env.example"

# 4. The old value is still in history. Rewriting history does not
#    recall the clones that already have it, which is why step 1 is
#    first and not last.
git log --oneline -- .env

Discussion

  • Be the first to comment on this lesson.