Skip to content

CWE-798: Use of Hard-coded Credentials - PHP

Overview

A credential written into PHP source is readable by everyone who can read the code, and it stays in version control history after the literal is deleted. Never embed passwords, API keys, database credentials, or encryption keys in PHP code or configuration files committed to version control. Use environment variables, configuration files, or secrets managers.

Primary Defence: Use cloud or enterprise secrets managers for production credentials. Use environment variables with getenv() or $_ENV as deployment-time injection, and use vlucas/phpdotenv only for local development with .env files excluded from version control. An environment variable is a reasonable way to inject a secret at process start and a poor place to store one - see CWE-526.

Rotate first, then refactor. A credential that has been committed is compromised regardless of whether the repository is public: it is in git log, in every clone, and in every deployment artefact built since - and for PHP, in every config.php~ or .bak an editor left in the document root. Deleting the literal from HEAD changes none of that. Revoke the value at the system that issued it before or alongside the code change.

Common Vulnerable Patterns

Hard-coded Database Credentials

<?php
// VULNERABLE - Credentials in source code
class DatabaseConnection {
    private const DB_HOST = 'localhost';
    private const DB_NAME = 'mydb';
    private const DB_USER = 'admin';
    private const DB_PASSWORD = 'P@ssw0rd123';  // DANGEROUS!

    public function getConnection(): PDO {
        $dsn = "mysql:host=" . self::DB_HOST . ";dbname=" . self::DB_NAME;
        return new PDO($dsn, self::DB_USER, self::DB_PASSWORD);
    }
}

Why this is vulnerable: The password is readable by anyone with file system access to the deployed code, and by anyone holding a clone of the repository, including its history - which is how these end up in public repositories. Whoever reads it connects to the database as admin, with that account's privileges.

Hard-coded API Keys

<?php
// VULNERABLE - API key in code
class ApiClient {
    private const API_KEY = 'sk_live_51H7x8y9z10a11b12c';  // DANGEROUS!
    private const API_SECRET = 'whsec_abcdef123456';  // DANGEROUS!

    public function makeRequest(): array {
        $ch = curl_init('https://api.example.com/data');
        curl_setopt($ch, CURLOPT_HTTPHEADER, [
            'Authorization: Bearer ' . self::API_KEY
        ]);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

        $response = curl_exec($ch);
        curl_close($ch);

        return json_decode($response, true);
    }
}

Why this is vulnerable: A live key held in a class constant is recoverable from the source - through version control, a directory-traversal or source-disclosure bug, or an error message or log line that prints the request it was used in. Whoever recovers it calls the API as the account that owns the key, and the usage is billed to that account.

Hard-coded Encryption Keys

<?php
// VULNERABLE - Encryption key in code
class Encryptor {
    private const SECRET_KEY = 'MySecretKey12345';  // DANGEROUS!

    public function encrypt(string $data): string {
        $iv = random_bytes(16);
        $encrypted = openssl_encrypt(
            $data,
            'AES-256-CBC',
            self::SECRET_KEY,
            OPENSSL_RAW_DATA,
            $iv
        );

        return base64_encode($iv . $encrypted);
    }
}

Why this is vulnerable: Hard-coded encryption keys in PHP source code mean all encrypted data can be decrypted if the code is exposed through source disclosure vulnerabilities, backup files (.php~), or version control, and keys cannot be rotated without code deployment.

Credentials in config.php (Committed to Git)

<?php
// VULNERABLE - config.php with real credentials
define('DB_HOST', 'localhost');
define('DB_NAME', 'mydb');
define('DB_USER', 'admin');
define('DB_PASSWORD', 'P@ssw0rd123');
define('API_KEY', 'sk_live_51H7x8y9z10a11b12c');
define('AWS_ACCESS_KEY', 'AKIAIOSFODNN7EXAMPLE');
define('AWS_SECRET_KEY', 'wJalrXUtnFEMI/K7MDENG/bPxRfiCY');

Why this is vulnerable: Configuration files with hard-coded credentials committed to git remain in version control history forever, are often accidentally deployed to production with debug settings, and can be exposed through web server misconfigurations or backup file disclosure (config.php~, config.php.bak).

Credentials the Product Itself Accepts

<?php
// VULNERABLE - the product ships the credentials it accepts
class AdminPortal {
    private const SUPPORT_USER = 'support';
    private const SUPPORT_PASS = 'Sup3rSecret!';  // DANGEROUS!

    // A fixed hash is a fixed credential: one password opens every install.
    private const MASTER_HASH = '$2y$12$<one bcrypt hash, compiled into every build>';  // DANGEROUS!

    private const LICENCE_KEY = 'ACME-PRO-9F3B-2C71';  // DANGEROUS!

    public function login(string $user, string $pass): bool {
        // Support back door - works on every copy ever shipped.
        if ($user === self::SUPPORT_USER && $pass === self::SUPPORT_PASS) {
            return true;
        }

        // Moving the password into a hash changes nothing: the password
        // that satisfies this hash is the same everywhere too.
        if ($user === 'admin' && password_verify($pass, self::MASTER_HASH)) {
            return true;
        }

        return $this->checkDatabase($user, $pass);
    }

    public function activate(string $key): bool {
        // Licence check against a literal - and a leaky comparison besides.
        return $key === self::LICENCE_KEY;
    }
}

Why this is vulnerable: these are not secrets the product needs to hold, they are authenticators it should never have accepted, and no secrets manager fixes them - reading Sup3rSecret! out of Vault leaves the same password working on every installation, and the attacker's copy of the product is as good as the customer's. The literals are recoverable by anyone with the source, a config.php~ backup, or a source-disclosure bug, and support back doors of this shape end up published in forums and scanner signatures. The hash is no better: password_verify is the right function used against the wrong thing, because a fixed digest is a fixed credential and the password behind it is shared by every deployment. The licence comparison adds a second problem - === on a secret returns as soon as the strings differ, so its timing leaks the key's length and its matching prefix (CWE-208).

Secure Patterns

Credentials the Product Accepts: Authenticate, Do Not Compare

Take this one first. It is the fix for Credentials the Product Itself Accepts above, and none of the secret-management patterns below address it. The built-in administrator is not a secret the product needs to hold, so there is nowhere correct to keep it: moving Sup3rSecret! into AWS Secrets Manager leaves the same credential working on every installation.

<?php
// SECURE - no credential is identical across installations

final class AdminAuth
{
    // A hash of a value nobody knows, verified against when the account does
    // not exist so a miss costs roughly what a hit costs. It is not a
    // credential: nothing accepts it, because no one holds the input.
    private const ABSENT_USER_HASH =
        '$2y$12$k7qfjFZ1jldkgb5x6GkUzehmL1cgv5GC2wKXZfkRlPh6Tx7zXzG8.';

    public function __construct(private PDO $db) {}

    /** No administrator enrolled means no credential works at all. */
    public function setupComplete(): bool
    {
        return (int) $this->db->query('SELECT COUNT(*) FROM admin_users')->fetchColumn() > 0;
    }

    /** First run: the operator chooses the first credential, nobody ships it. */
    public function enrolFirstAdmin(string $username, string $password): void
    {
        if ($this->setupComplete()) {
            throw new RuntimeException('an administrator is already enrolled');
        }
        $stmt = $this->db->prepare(
            'INSERT INTO admin_users (username, password_hash) VALUES (?, ?)'
        );
        $stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT)]);
    }

    public function login(string $username, string $password): bool
    {
        $stmt = $this->db->prepare('SELECT password_hash FROM admin_users WHERE username = ?');
        $stmt->execute([$username]);
        $hash = $stmt->fetchColumn();

        if ($hash === false) {
            // Same answer, comparable cost, for "no such account".
            password_verify($password, self::ABSENT_USER_HASH);
            return false;
        }

        if (!password_verify($password, $hash)) {
            return false;
        }

        // Cost and algorithm move over time; re-hash on a successful login so
        // stored hashes follow the current default.
        if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
            $update = $this->db->prepare(
                'UPDATE admin_users SET password_hash = ? WHERE username = ?'
            );
            $update->execute([password_hash($password, PASSWORD_DEFAULT), $username]);
        }

        return true;
    }
}

/**
 * The enrolment gate. Every route but the setup route refuses to serve until
 * an administrator exists, so a fresh install has no working credential rather
 * than a well-known one.
 */
function gateRequest(AdminAuth $auth, string $route): void
{
    if (!$auth->setupComplete() && $route !== '/setup') {
        http_response_code(503);
        exit('Setup incomplete - enrol an administrator before using this system.');
    }
}

/**
 * Where the product genuinely must accept a fixed token - one issued per
 * installation at setup time, never one written into the source - compare it
 * in constant time.
 */
function deploymentTokenAccepted(string $presented): bool
{
    $expected = getenv('DEPLOY_TOKEN') ?: '';

    return $expected !== '' && hash_equals($expected, $presented);
}

Why this works: no credential literal is left to compare against, so there is nothing in the source that an attacker can lift and replay against every other deployment. password_hash and password_verify check a per-user hash the installation generated rather than testing equality against a shipped value, and password_needs_rehash lets stored hashes follow the current default cost without asking anyone to re-enrol. gateRequest is what stops the default coming back: a build that refuses to serve any route but /setup until admin_users has a row cannot ship with a working admin/Sup3rSecret!, where a default that merely logs a warning survives into production. The ABSENT_USER_HASH verification keeps the unknown-account path costing roughly what a real one costs, which is CWE-208 territory. For a fixed token the product genuinely must accept - one issued per installation at setup time, never one written into the source - hash_equals is PHP's constant-time comparison; === on a secret leaks its length and matching prefix through timing. Store the per-user hashes the way CWE-916 describes, and treat every instance already running the old build as compromised: removing the literal in the next release does nothing for installations on the current one.

Environment Variables with getenv()

<?php
// SECURE - Read from environment variables. The test below requires this
// class as 'database-connection.php'.
class DatabaseConnection {
    private string $dbHost;
    private string $dbName;
    private string $dbUser;
    private string $dbPassword;

    public function __construct() {
        $this->dbHost = getenv('DB_HOST') ?: throw new RuntimeException('DB_HOST not configured');
        $this->dbName = getenv('DB_NAME') ?: throw new RuntimeException('DB_NAME not configured');
        $this->dbUser = getenv('DB_USER') ?: throw new RuntimeException('DB_USER not configured');
        $this->dbPassword = getenv('DB_PASSWORD') ?: throw new RuntimeException('DB_PASSWORD not configured');
    }

    public function getConnection(): PDO {
        $dsn = "mysql:host={$this->dbHost};dbname={$this->dbName}";
        return new PDO($dsn, $this->dbUser, $this->dbPassword, [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
        ]);
    }
}

// Set environment variables in Apache/Nginx or shell:
// export DB_HOST=localhost
// export DB_NAME=mydb
// export DB_USER=admin
// export DB_PASSWORD=SecurePassword123

Why this works: Environment variables (getenv()) keep credentials outside source code and can be configured per environment without code changes. The constructor throws RuntimeException when a variable is unset, so a misconfigured deployment fails at startup instead of connecting with an empty password. For production, inject these values from a secret store or platform secret mechanism and avoid exposing them through phpinfo pages, debug output, process metadata, web server config leaks, or logs - see CWE-526.

vlucas/phpdotenv (.env files)

<?php
// SECURE - Use vlucas/phpdotenv for environment variables
require_once __DIR__ . '/vendor/autoload.php';

use Dotenv\Dotenv;

// Load environment variables from .env file
$dotenv = Dotenv::createImmutable(__DIR__);
$dotenv->load();

// Access environment variables
$apiKey = $_ENV['API_KEY'] ?? throw new RuntimeException('API_KEY not configured');
$apiSecret = $_ENV['API_SECRET'] ?? throw new RuntimeException('API_SECRET not configured');

class ApiClient {
    private string $apiKey;
    private string $apiSecret;

    public function __construct() {
        $this->apiKey = $_ENV['API_KEY'] ?? throw new RuntimeException('API_KEY not configured');
        $this->apiSecret = $_ENV['API_SECRET'] ?? throw new RuntimeException('API_SECRET not configured');
    }

    public function makeRequest(): array {
        $ch = curl_init('https://api.example.com/data');
        curl_setopt($ch, CURLOPT_HTTPHEADER, [
            "Authorization: Bearer {$this->apiKey}"
        ]);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

        $response = curl_exec($ch);

        return json_decode($response, true);
    }
}

// .env file (NOT committed to version control):
/*
API_KEY=sk_live_51H7x8y9z10a11b12c
API_SECRET=whsec_abcdef123456
DB_PASSWORD=SecurePassword123
DB_HOST=localhost
DB_NAME=mydb
DB_USER=admin
*/

// Install: composer require vlucas/phpdotenv

Why this works: vlucas/phpdotenv loads environment variables from .env files, allowing local development without system-level configuration. The .env file is excluded from version control (.gitignore), keeping secrets out of Git history. Variables are loaded into $_ENV at runtime, not compiled into code. The ?? throw new RuntimeException guard fails the client's construction when a required variable is missing. Different .env files can exist per environment without touching code.

AWS Secrets Manager

<?php
// SECURE - AWS Secrets Manager
use Aws\SecretsManager\SecretsManagerClient;
use Aws\Exception\AwsException;

class SecretsManager {
    private SecretsManagerClient $client;

    public function __construct() {
        $this->client = new SecretsManagerClient([
            'version' => '2017-10-17',
            'region' => getenv('AWS_REGION') ?: 'us-east-1'
        ]);
    }

    public function getDatabaseCredentials(): array {
        try {
            $result = $this->client->getSecretValue([
                'SecretId' => 'prod/database/credentials'
            ]);

            if (isset($result['SecretString'])) {
                return json_decode($result['SecretString'], true);
            }

            throw new RuntimeException('Secret not found');

        } catch (AwsException $e) {
            throw new RuntimeException('Failed to retrieve secret: ' . $e->getMessage());
        }
    }

    public function getConnection(): PDO {
        $creds = $this->getDatabaseCredentials();

        $dsn = "mysql:host={$creds['host']};dbname={$creds['database']}";
        return new PDO($dsn, $creds['username'], $creds['password']);
    }
}

// Install: composer require aws/aws-sdk-php

// AWS credentials from environment or IAM role:
// export AWS_ACCESS_KEY_ID=your_access_key
// export AWS_SECRET_ACCESS_KEY=your_secret_key

Why this works: AWS Secrets Manager holds the secret encrypted at rest under KMS and in transit, with IAM controlling who may read it and CloudTrail recording each read. The SDK retrieves it at runtime using AWS credentials from the provider chain, preferably IAM roles or workload identity in production. Versioning supports gradual rollout of rotated credentials. Nothing sensitive is left in source code or the deployment artifact.

External Configuration Files

<?php
// SECURE - Load config from external file NOT in version control
class ConfigLoader {
    public static function loadSecrets(): array {
        // Load from file outside web root
        $configPath = getenv('CONFIG_PATH') ?: '/etc/myapp/secrets.json';

        if (!file_exists($configPath)) {
            throw new RuntimeException("Configuration file not found: $configPath");
        }

        $json = file_get_contents($configPath);
        $config = json_decode($json, true);

        if (json_last_error() !== JSON_ERROR_NONE) {
            throw new RuntimeException('Invalid configuration file');
        }

        return $config;
    }
}

// Usage:
$secrets = ConfigLoader::loadSecrets();
$dbPassword = $secrets['database']['password'];

// /etc/myapp/secrets.json (NOT in version control or web root):
/*
{
    "database": {
        "host": "localhost",
        "name": "mydb",
        "user": "admin",
        "password": "SecurePassword123"
    },
    "api": {
        "key": "sk_live_51H7x8y9z10a11b12c",
        "secret": "whsec_abcdef123456"
    }
}
*/

Why this works: Putting secrets in an external file outside the web root keeps them out of source control, out of the deployable artifact, and so out of git history and container layers. The code loads the file path from an environment variable (or a locked-down default), so operations can rotate or swap credentials centrally by replacing the file, and each environment can carry different values without touching code. Failing fast on missing/invalid JSON prevents the app from running with empty defaults. Keeping the file outside the document root blocks accidental download via HTTP, and OS-level permissions can restrict which service account can read it.

Framework-Specific Guidance

Laravel

<?php
// SECURE - Laravel with .env

// .env file (NOT committed - add to .gitignore):
/*
APP_NAME=MyApp
APP_ENV=production
APP_KEY=base64:generated_key_here
APP_DEBUG=false

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=mydb
DB_USERNAME=admin
DB_PASSWORD=SecurePassword123

STRIPE_KEY=sk_live_51H7x8y9z10a11b12c
STRIPE_SECRET=whsec_abcdef123456

AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCY
*/

// config/database.php (committed - references environment variables):
return [
    'connections' => [
        'mysql' => [
            'driver' => 'mysql',
            'host' => env('DB_HOST', '127.0.0.1'),
            'port' => env('DB_PORT', '3306'),
            'database' => env('DB_DATABASE', 'forge'),
            'username' => env('DB_USERNAME', 'forge'),
            'password' => env('DB_PASSWORD', ''),
        ],
    ],
];

// Access in controllers/services - config(), never env():
class PaymentController extends Controller {
    public function processPayment() {
        $stripeKey = config('services.stripe.key');

        // Use the key
    }
}

// config/services.php - the ONLY layer that may call env():
return [
    'stripe' => [
        'key' => env('STRIPE_KEY'),
        'secret' => env('STRIPE_SECRET'),
    ],
];

// Generate app key:
// php artisan key:generate

env() and config() are not interchangeable, and the difference only shows up in production. php artisan config:cache is a standard deployment step, and once it has run Laravel stops loading .env entirely - so env() returns null for anything not also present as a real OS environment variable, while config() reads from the cached array and keeps working. A controller calling env('STRIPE_KEY') therefore works in development and returns null on the deployed host, which surfaces as an authentication failure against Stripe rather than as a configuration error. Laravel's own documentation is explicit: call env() only from files in config/.

Symfony

Symfony inverts the Laravel convention above, and getting it backwards is a common way to commit a secret. Symfony's .env is committed - it holds the list of variables and their non-secret defaults, and .env.<environment> is committed too. The files that must not be committed are the .local ones, which is what Symfony's own shipped .gitignore excludes. Real values go in .env.local, in the process environment, or - for production - in Symfony's encrypted secrets vault, which stores ciphertext in the repository and keeps the decryption key out of it.

# config/packages/doctrine.yaml:
doctrine:
    dbal:
        url: '%env(resolve:DATABASE_URL)%'
# services.yaml:
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\Service\PaymentService:
        arguments:
            $stripeApiKey: '%env(STRIPE_API_KEY)%'
<?php
// src/Service/PaymentService.php:

// SECURE - Symfony with .env

// .env (COMMITTED): variable names and non-secret defaults only
/*
APP_ENV=prod
APP_SECRET=
DATABASE_URL="mysql://app_user:@127.0.0.1:3306/mydb?serverVersion=8.0"
STRIPE_API_KEY=
MAILER_DSN=
*/

// .env.local (NOT committed - excluded by Symfony's own .gitignore):
// the same keys with real values, for one machine

// Production: prefer the secrets vault over any .env file
// php bin/console secrets:set DATABASE_URL
// php bin/console secrets:set STRIPE_API_KEY
// Commit config/secrets/prod/*, keep prod.decrypt.private.php out of the repo
// and inject it (or SYMFONY_DECRYPTION_SECRET) at deploy time.


namespace App\Service;

class PaymentService {
    private string $stripeApiKey;

    public function __construct(string $stripeApiKey) {
        $this->stripeApiKey = $stripeApiKey;
    }

    public function processPayment(): void {
        // Use $this->stripeApiKey
    }
}

// Install symfony/dotenv: composer require symfony/dotenv

Apache/Nginx Configuration

The web server can inject credentials as environment variables, but only the non-secret parts belong in the configuration you keep in version control. Writing the password into a VirtualHost block or an .htaccess file moves the credential out of the code and leaves it hard-coded in a tracked file, which is the same exposure under a different filename. .htaccess is the worst case of this: it usually sits in the document root and is usually committed.

Split it. Non-secret settings stay in the tracked config; secrets come from a file the deployment writes, outside the repository and outside the web root, owned by root with 0600 permissions.

Apache VirtualHost

# Tracked config - non-secret values only

<VirtualHost *:80>
    SetEnv DB_HOST localhost
    SetEnv DB_NAME mydb
    SetEnv DB_USER app_user

    # Secrets come from a file the deployment writes, never from this file
    Include /etc/apache2/secrets/app-secrets.conf
</VirtualHost>

# /etc/apache2/secrets/app-secrets.conf - root:root, chmod 0600,
# written by the deployment from the secret store, never committed:
#     SetEnv DB_PASSWORD <injected at deploy time>
#     SetEnv API_KEY     <injected at deploy time>

# Access in PHP:

# $dbPassword = getenv('DB_PASSWORD');

Never use .htaccess for this. It is read from the document root, is routinely committed alongside the application, and a server misconfiguration can serve it as a static file.

Nginx with php-fpm

# Tracked config - non-secret values only

location ~ \.php$ {
    fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
    fastcgi_param DB_HOST localhost;
    fastcgi_param DB_NAME mydb;
    fastcgi_param DB_USER app_user;
}

# Secrets belong in the php-fpm pool, not here. In the pool config, read
# them from a protected environment file the deployment writes:
#     /etc/php/8.1/fpm/pool.d/app.conf   ->  env[DB_PASSWORD] = $DB_PASSWORD
#     systemd drop-in for php8.1-fpm     ->  EnvironmentFile=/etc/app/secrets.env
# with /etc/app/secrets.env owned by root and chmod 0600.

# Access in PHP:

# $dbPassword = $_SERVER['DB_PASSWORD'];

Why this works: the split is what makes it safe, not the mechanism. Both servers read the same environment variables either way; the difference is that the file holding the secret is created by the deployment on the target host, so it never exists in the repository, in an image layer, or in a pipeline log. The tracked configuration names the variables and nothing else, which also means a reviewer can tell at a glance whether a secret has crept in.

Testing with Test Credentials

<?php
// SECURE - Use test credentials for unit tests
use PHPUnit\Framework\TestCase;

// The class under test, from the Environment Variables section above
require_once 'database-connection.php';

class DatabaseConnectionTest extends TestCase {
    protected function setUp(): void {
        // Set test environment variables
        putenv('DB_HOST=localhost');
        putenv('DB_NAME=test_db');
        putenv('DB_USER=test_user');
        putenv('DB_PASSWORD=test_password');
    }

    public function testConnection(): void {
        $db = new DatabaseConnection();
        $conn = $db->getConnection();

        $this->assertInstanceOf(PDO::class, $conn);
    }

    protected function tearDown(): void {
        // Clean up environment variables
        putenv('DB_HOST');
        putenv('DB_NAME');
        putenv('DB_USER');
        putenv('DB_PASSWORD');
    }
}

// Using Docker for integration tests:
// docker-compose.yml:
/*
version: '3.8'
services:
  test_db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: test_root_password
      MYSQL_DATABASE: test_db
      MYSQL_USER: test_user
      MYSQL_PASSWORD: test_password
    ports:

      - "3307:3306"

*/

.gitignore Best Practices

Pick the framework block that applies rather than taking all of them: Laravel ignores .env and commits .env.example, while Symfony commits .env and ignores the .local variants, so a project that uses both rules commits a secret or loses its variable list.

# Add these patterns to .gitignore

# Environment files (plain PHP and Laravel; see the Symfony note below)

.env
.env.local
.env.*.local
.env.backup

# Configuration files with secrets

config/secrets.php
config/local.php

# WordPress

wp-config.php

# Laravel

.env
.env.backup
.phpunit.result.cache

# Symfony - NOTE: Symfony's own .env IS committed, so do not add a blanket

# .env rule to a Symfony project. Exclude only the .local variants.

.env.local
.env.local.php
.env.*.local

# Credentials

*.pem
*.key
credentials.json

# Composer

vendor/

# IDE

.idea/
.vscode/

Creating .env.example Template

# .env.example (committed to version control)

# Copy this to .env and fill in real values

# Database

DB_HOST=localhost
DB_NAME=mydb
DB_USER=admin
DB_PASSWORD=your_password_here

# API Keys

API_KEY=your_api_key_here
API_SECRET=your_api_secret_here

# Laravel

APP_KEY=base64:your_key_here
APP_ENV=local
APP_DEBUG=true

# Stripe

STRIPE_KEY=your_stripe_key
STRIPE_SECRET=your_stripe_secret

Detecting Hard-coded Secrets

Using TruffleHog

# Install TruffleHog. It is a Go binary - the PyPI "truffleHog" package is

# the abandoned v2 from 2018 and does not accept the flags below.

brew install trufflehog
# or: curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin

# Scan a working tree

trufflehog filesystem .

# Scan a repository's history

trufflehog git file:///path/to/repo --results=verified

Using git-secrets

# Install git-secrets

# https://github.com/awslabs/git-secrets

# Register the built-in AWS patterns, then add your own.

# Patterns are POSIX extended regular expressions - use [[:space:]], not \s.

# Quote with double quotes: a single-quoted shell string cannot contain a

# single quote, so the obvious spelling is an unterminated-quote error.

git secrets --register-aws
git secrets --add "password[[:space:]]*=[[:space:]]*[\"']"
git secrets --add "api_key[[:space:]]*=[[:space:]]*[\"']"

# Scan repository

git secrets --scan

# Add pre-commit hook

git secrets --install

Why not a hand-written scanner

A keyword regex over *.php is the obvious thing to write and it is strictly worse than either tool above. It sees only the working tree, so it misses every secret that is in the history and nowhere else - which is the population that matters after a fix has been applied. It matches on the variable name, so it flags $password = $_ENV['DB_PASSWORD'] and misses a base64 blob, a PEM block, or an AWS key in a variable called $k. And it cannot tell a live credential from a fixture. TruffleHog's --results=verified calls the issuing service to find out, which is the one judgement a regex cannot make.

Keep the custom pattern for the shapes specific to your codebase, and add it to gitleaks.toml or git secrets --add so it runs in the same pre-commit hook as everything else, rather than as a script somebody has to remember.

Common Pitfalls

  • Moving credentials from define('DB_PASSWORD', '...') in config.php into a .env file loaded by vlucas/phpdotenv, but the old config.php with the real password is still tracked in git history (or a config.php.bak backup file is still deployed alongside it) - the active code path is fixed, but the credential is still recoverable.
  • Using getenv() correctly in application code while the real value is set directly in a committed Apache VirtualHost block or Nginx fastcgi_param line (SetEnv DB_PASSWORD SecurePassword123) instead of injected by the deployment platform - this is deployment-time-looking configuration that is actually hard-coded in a version-controlled server config file.
  • Laravel or Symfony apps that correctly read env('DB_PASSWORD') in config/database.php, but a seeder, artisan command, or test fixture elsewhere in the codebase still hard-codes a real (not test) credential for "convenience" - the framework-level fix doesn't cover every code path that touches the same system.
  • Storing the AWS/Vault credentials needed to reach the secrets manager itself (AWS_ACCESS_KEY_ID, a Vault token) as hard-coded values in the same config.php the secrets manager was meant to replace - this just moves the hard-coded-credential problem one level up the chain instead of removing it.

Additional Resources