Skip to content

CWE-798: Hard-coded Credentials - PHP

Overview

Hard-coded credentials in PHP create critical security vulnerabilities. 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 environment variables with getenv() or $_ENV, vlucas/phpdotenv for local development, or cloud secrets managers for production credentials.

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: Hard-coded database credentials in PHP source code are exposed to anyone with file system access, visible in version control history, and often accidentally committed to public repositories, allowing attackers to gain direct database access with potentially elevated 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: API keys embedded in PHP code are visible in web server logs, error messages, and version control, and can be extracted from PHP files if directory traversal or source code disclosure vulnerabilities exist, allowing unauthorized API access and potential billing fraud.

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).

Secure Patterns

Environment Variables with getenv()

<?php
// SECURE - Read from environment variables
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, stored at the server/container level. Credentials never enter version control and can be configured per environment without code changes. The validation logic ensures fail-fast behavior (throwing RuntimeException) if credentials are missing, preventing runtime errors. Environment variables can be updated through Apache/Nginx config, Docker, Kubernetes secrets, or shell without redeployment.

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);
        curl_close($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 validation (?? throw new RuntimeException) ensures required credentials are present. 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 centralizes secret storage with encryption (KMS), automatic rotation, and IAM-based access control. The SDK retrieves secrets at runtime using AWS credentials from environment/IAM roles (not hardcoded). Secrets are encrypted at rest and in transit. Supports versioning for gradual rollout of rotated credentials. CloudTrail logs all secret access for compliance. The application never stores credentials - it fetches them when needed.

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 and out of the deployable artifact. 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. 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. This separation lets you manage different secrets per environment without touching code and avoids leaking secrets in git history or container layers.

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:
use Illuminate\Support\Facades\Config;

class PaymentController extends Controller {
    public function processPayment() {
        $stripeKey = env('STRIPE_KEY');
        // Or
        $stripeKey = config('services.stripe.key');

        // Use the key
    }
}

// config/services.php:
return [
    'stripe' => [
        'key' => env('STRIPE_KEY'),
        'secret' => env('STRIPE_SECRET'),
    ],
];

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

Symfony

<?php
// SECURE - Symfony with .env

// .env file (NOT committed):
/*
APP_ENV=prod
APP_SECRET=generated_secret_here

DATABASE_URL="mysql://admin:SecurePassword123@127.0.0.1:3306/mydb?serverVersion=8.0"

STRIPE_API_KEY=sk_live_51H7x8y9z10a11b12c
MAILER_DSN=smtp://user:pass@smtp.example.com:587
*/

// config/packages/doctrine.yaml (committed):
doctrine:
    dbal:
        url: '%env(resolve:DATABASE_URL)%'

// services.yaml:
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\Service\PaymentService:
        arguments:
            $stripeApiKey: '%env(STRIPE_API_KEY)%'

// src/Service/PaymentService.php:
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

Apache .htaccess or VirtualHost

# SECURE - Set environment variables in Apache

<VirtualHost *:80>
    SetEnv DB_HOST localhost
    SetEnv DB_NAME mydb
    SetEnv DB_USER admin
    SetEnv DB_PASSWORD SecurePassword123
    SetEnv API_KEY sk_live_51H7x8y9z10a11b12c
</VirtualHost>

# Access in PHP:

# $dbPassword = getenv('DB_PASSWORD');

Nginx with php-fpm

# SECURE - Set environment variables in Nginx

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 admin;
    fastcgi_param DB_PASSWORD SecurePassword123;
    fastcgi_param API_KEY sk_live_51H7x8y9z10a11b12c;
}

# Access in PHP:

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

Testing with Test Credentials

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

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

# Add these patterns to .gitignore

# Environment files

.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

.env.local
.env.local.php

# 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

pip install truffleHog

# Scan repository

trufflehog --regex --entropy=True .

# Scan specific files

trufflehog --regex --entropy=True file:///path/to/repo

Using git-secrets

# Install git-secrets

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

# Add patterns

git secrets --add 'password\s*=\s*["\'][^"\']+["\']'
git secrets --add 'api_key\s*=\s*["\'][^"\']+["\']'

# Scan repository

git secrets --scan

# Add pre-commit hook

git secrets --install

Custom PHP Scanner

<?php
// Simple credential scanner
function scanForHardcodedSecrets(string $directory): array {
    $findings = [];
    $patterns = [
        '/password\s*=\s*["\'][^"\']+["\']/i',
        '/api[_-]?key\s*=\s*["\'][^"\']+["\']/i',
        '/secret\s*=\s*["\'][^"\']+["\']/i',
        '/token\s*=\s*["\'][^"\']+["\']/i',
    ];

    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($directory)
    );

    foreach ($iterator as $file) {
        if ($file->isFile() && $file->getExtension() === 'php') {
            $content = file_get_contents($file->getPathname());

            foreach ($patterns as $pattern) {
                if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE)) {
                    $findings[] = [
                        'file' => $file->getPathname(),
                        'matches' => $matches[0]
                    ];
                }
            }
        }
    }

    return $findings;
}

$findings = scanForHardcodedSecrets(__DIR__ . '/src');
print_r($findings);

Verification

After implementing the recommended secure patterns, verify the fix through multiple approaches:

  • Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
  • Code review: Confirm all instances use the secure pattern (parameterized queries, safe APIs, proper encoding) with no string concatenation or unsafe operations
  • Static analysis: Use security scanners to verify no new vulnerabilities exist and the original finding is resolved
  • Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
  • Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
  • Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
  • Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
  • Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced

Additional Resources