Skip to content

CWE-522: Insufficiently Protected Credentials - PHP

Overview

Insufficiently protected credentials in PHP commonly appear as database passwords or API keys hardcoded in config.php, or held in a .env file that is committed to version control. The other common form is password storage: md5(), sha1(), or any other fast hash lets an attacker who compromises the database crack most of the passwords within hours using GPU-accelerated tools or precomputed rainbow tables.

PHP has built-in functions designed specifically for secure password storage - password_hash() and password_verify() - which implement BCrypt (default) or Argon2 with automatic salting and configurable cost factors. These should be used exclusively for password storage.

Primary Defence: Load credentials from a secret store, or from environment variables that the web server or orchestrator injects at process start rather than ones kept in a committed .env file - see CWE-526 for why the environment is an injection mechanism and not a storage location. Hash passwords with password_hash($password, PASSWORD_BCRYPT) or PASSWORD_ARGON2ID. Never commit secrets to version control.

Common Vulnerable Patterns

Hardcoded Database Credentials

<?php
// VULNERABLE - credentials visible in source code and version control
define('DB_HOST', 'prod.db.example.com');
define('DB_USER', 'app_user');
define('DB_PASS', '<redacted-production-password>');
define('DB_NAME', 'appdb');

$pdo = new PDO(
    "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME,
    DB_USER,
    DB_PASS
);

Why this is vulnerable:

  • Any developer with repository access can see the production password. The credentials also persist in Git history forever, even after the values are rotated, unless a full history rewrite is performed.

Password Stored with md5()

<?php
// VULNERABLE - md5 is a fast hash designed for data integrity, not passwords
function storeUserPassword(string $username, string $password): void {
    $hash = md5($password); // e.g., "5f4dcc3b5aa765d61d8327deb882cf99"
    // INSERT INTO users (username, password_hash) VALUES (?, ?)
    saveToDatabase($username, $hash);
}

function verifyUserPassword(string $username, string $password): bool {
    $stored = getHashFromDatabase($username);
    return md5($password) === $stored; // timing-safe comparison also missing
}

Why this is vulnerable:

  • MD5 can be computed billions of times per second on modern GPUs. A database containing MD5 hashes can be fully cracked in hours using tools like Hashcat with a common password list. MD5 also has no salt, enabling rainbow table attacks.

API Key in a Committed .env File

# VULNERABLE - .env tracked by Git, and living under the document root
$ git ls-files public_html/.env
public_html/.env

$ cat public_html/.env
PAYMENT_PROVIDER_SECRET_KEY=<redacted-production-api-key>
EMAIL_PROVIDER_API_KEY=<redacted-email-api-key>

Why this is vulnerable:

  • The weakness is in the file's location and its tracked status, not in the PHP that reads it - $_ENV['PAYMENT_PROVIDER_SECRET_KEY'] is the right way to consume a secret and stays unchanged after the fix. Two separate exposures are stacked here. Committing .env even once puts the credentials permanently in Git history, so removing the file and adding it to .gitignore leaves every existing clone, fork and CI artifact holding the values; only rotation at the provider closes that. Placing it under the document root adds a second path: a server that stops interpreting .env as PHP - a misconfiguration, or simply the default for a dotfile - serves it verbatim to anyone who requests /.env, which is among the most heavily scanned paths on the public internet.

Secure Patterns

Credentials from Environment Variables

<?php
// SECURE - credentials loaded from environment, never hardcoded
$dbHost = getenv('DB_HOST') ?: throw new RuntimeException('DB_HOST not set');
$dbUser = getenv('DB_USER') ?: throw new RuntimeException('DB_USER not set');
$dbPass = getenv('DB_PASS') ?: throw new RuntimeException('DB_PASS not set');
$dbName = getenv('DB_NAME') ?: throw new RuntimeException('DB_NAME not set');

$dsn = "mysql:host=$dbHost;dbname=$dbName;charset=utf8mb4";
$pdo = new PDO($dsn, $dbUser, $dbPass, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);

Why this works:

  • Environment variables are set by the web server (SetEnv in Apache, fastcgi_param in Nginx) or the container/cloud orchestrator. They are never stored in files that could be committed to version control. Using ?: with an exception ensures a fast failure if a required credential is missing.

.env File Outside Web Root (Development)

<?php
// SECURE - use vlucas/phpdotenv to load .env from outside the web root
require_once __DIR__ . '/../vendor/autoload.php';

$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../'); // one level above public_html
$dotenv->load();
$dotenv->required(['DB_HOST', 'DB_USER', 'DB_PASS', 'DB_NAME']);

$pdo = new PDO(
    sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $_ENV['DB_HOST'], $_ENV['DB_NAME']),
    $_ENV['DB_USER'],
    $_ENV['DB_PASS']
);

Why this works:

  • Placing .env above the web root makes it inaccessible via HTTP. $dotenv->required() validates that all expected variables are present and throws an exception during startup if any are missing.

Password Hashing with password_hash()

<?php
// SECURE - BCrypt with cost 12; automatically salted
function registerUser(string $username, string $plainPassword): void {
    $hash = password_hash($plainPassword, PASSWORD_BCRYPT, ['cost' => 12]);
    // INSERT INTO users (username, password_hash) VALUES (?, ?)
    saveToDatabase($username, $hash); // Store $hash, never $plainPassword
}

// A fixed cost-12 BCrypt hash, used only to consume the same time a real
// verification would. Its cost must match the one registerUser() writes, or the
// two paths diverge again. The return value is deliberately discarded.
const DUMMY_HASH = '$2y$12$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG';

function loginUser(string $username, string $submittedPassword): bool {
    $storedHash = getHashFromDatabase($username); // Retrieve the BCrypt hash
    if ($storedHash === null) {
        // SECURE - burn the same work on an unknown user, then fail. Returning here
        // without verifying answers in microseconds where a real user costs ~200ms,
        // which tells an attacker which usernames exist.
        password_verify($submittedPassword, DUMMY_HASH);
        return false;
    }
    // SECURE - timing-safe comparison; automatically handles algorithm differences
    return password_verify($submittedPassword, $storedHash);
}

// SECURE - upgrade hash cost when the stored cost is too low
function maybeRehash(string $userId, string $plainPassword, string $storedHash): void {
    if (password_needs_rehash($storedHash, PASSWORD_BCRYPT, ['cost' => 12])) {
        $newHash = password_hash($plainPassword, PASSWORD_BCRYPT, ['cost' => 12]);
        updatePasswordHash($userId, $newHash);
    }
}

Why this works:

  • password_hash() uses BCrypt by default, which applies a randomly generated salt automatically and runs through 2^cost iterations. At cost 12, each hash takes ~200-400ms on a modern server - acceptable for login but prohibitively slow for brute-force attacks.
  • password_verify() is timing-safe and handles the salt and algorithm version embedded in the stored hash string.
  • password_needs_rehash() allows gradual cost upgrades on subsequent logins without forcing a password reset.
  • Verifying an unknown username against a dummy hash removes the response-time difference that otherwise enumerates accounts. It is not free - the login endpoint now spends the full hashing cost on every request, valid or not - so pair it with rate limiting on the endpoint.

Testing

  • Normal input: authenticate with valid users after moving credentials to environment configuration and migrating password hashing.
  • Boundary input: test missing .env values, rotated database passwords, and legacy password hashes that should be rehashed on login.
  • Malicious input: search source and Git history for known credential strings and verify old MD5/SHA password comparisons no longer authenticate.
  • Inspect a stored hash directly: it should begin with $2y$ for BCrypt and differ between two hashes of the same password.
  • Time the login endpoint for a username that exists against one that does not: the two should be within noise of each other, both paying the full hashing cost. A sub-millisecond answer for the unknown user is the enumeration oracle, and it is invisible to a re-scan.
  • Search history as well as the working tree - git log -p | grep -iE "(password|secret|key)\s*=" - since a credential deleted from the working tree stays readable in every clone until it is actually rotated at the provider.

Additional Resources