Skip to content

CWE-522: Insufficiently Protected Credentials - PHP

Overview

Insufficiently protected credentials in PHP commonly appear as database passwords or API keys hardcoded directly in config.php, committed .env files, or credentials embedded in source code committed to version control. A second critical aspect is password storage: using md5(), sha1(), or any fast hash allows an attacker who compromises the database to crack the majority of 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 environment variables set outside the web root. 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 Committed .env File

<?php
// VULNERABLE - .env file committed to Git
// .env contents:
// PAYMENT_PROVIDER_SECRET_KEY=<redacted-production-api-key>
// EMAIL_PROVIDER_API_KEY=<redacted-email-api-key>

// This is fine if .env is in .gitignore - dangerous if it was ever committed
$paymentProviderKey = $_ENV['PAYMENT_PROVIDER_SECRET_KEY'];

Why this is vulnerable:

  • If .env is committed even once, the credentials are permanently in Git history. Even after removing the file and rotating keys, old versions remain accessible to anyone who clones the repository or accesses CI/CD artifacts.

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
}

function loginUser(string $username, string $submittedPassword): bool {
    $storedHash = getHashFromDatabase($username); // Retrieve the BCrypt hash
    if ($storedHash === null) {
        return false; // User not found - constant-time to avoid timing oracle
    }
    // 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 extremely 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.

Remediation Steps

Locate the Finding

  • Source: Source files, config.php, .env committed to Git
  • Sink: define('DB_PASS', 'hardcoded'), md5($password), sha1($password), hash('sha256', $password) for passwords

Apply the Fix

  • PRIORITY 1: Move all credentials to environment variables; add .env to .gitignore
  • PRIORITY 2: Scan Git history: git log -p | grep -iE "(password|secret|key)\s*=" - rotate any found credentials
  • PRIORITY 3: Migrate password hashes to BCrypt: rehash on next successful login if existing hashes use MD5/SHA

Verify the Fix

  • Confirm config.php and .env contain no credentials; check Git log for history
  • Register a test user; inspect the stored hash - it should start with $2y$ (BCrypt) and differ between registrations
  • Rescan with the security scanner to confirm the finding is resolved

Check for Similar Issues

Search for: md5(, sha1(, hash('sha256', hash('md5', hardcoded password= strings in PHP files

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.

Additional Resources