Back to Blog
Platform Guides

Secrets and Environment Variable Management Across WordPress Hosting Platforms

Marcus Chen
44 min read

Why Secrets Management Matters More Than You Think

WordPress sites hold sensitive credentials: database passwords, API keys for payment gateways, SMTP credentials, third-party service tokens, and encryption salts. A single leaked Stripe secret key can drain a merchant account. A compromised database password opens every user record, every order, every private post to an attacker.

Yet the WordPress ecosystem has historically treated secrets management as an afterthought. The default wp-config.php approach of hardcoding credentials in a PHP file works for a solo developer on shared hosting. It falls apart the moment you add staging environments, CI/CD pipelines, team members with varying access levels, or compliance requirements that mandate credential rotation.

Every managed WordPress hosting platform handles secrets differently. Some offer first-class secret management APIs. Others still rely on editing PHP files through a dashboard. The differences matter enormously when you are migrating between platforms, setting up staging environments, or building deployment pipelines that need to inject credentials at runtime.

This guide covers the concrete mechanics of secrets and environment variable management across seven major WordPress hosting platforms, plus self-hosted approaches using phpdotenv, Docker, and Kubernetes. Every CLI command and code example comes from production experience. Where a platform’s approach has sharp edges or hidden limitations, I will call them out directly.

The Baseline: How WordPress Uses wp-config.php

Before examining platform-specific approaches, we need to understand the default WordPress mechanism. WordPress reads wp-config.php at bootstrap time and expects certain PHP constants to be defined there:

// The classic wp-config.php pattern
define( 'DB_NAME', 'wordpress' );
define( 'DB_USER', 'wp_user' );
define( 'DB_PASSWORD', 's3cretP@ssw0rd' );
define( 'DB_HOST', 'localhost' );

// Authentication keys and salts
define( 'AUTH_KEY', 'put your unique phrase here' );
define( 'SECURE_AUTH_KEY', 'put your unique phrase here' );
// ... more salts

// Stripe API keys (custom)
define( 'STRIPE_SECRET_KEY', 'sk_live_...' );
define( 'STRIPE_PUBLISHABLE_KEY', 'pk_live_...' );

This works. It is also a security liability in multiple ways:

Version control exposure. If wp-config.php gets committed to a Git repository (and it does, constantly), every credential in the file becomes part of the repository history. Even if you remove the file later, the secrets persist in Git history unless you rewrite it.

Environment coupling. Hardcoded values mean you cannot use the same codebase across development, staging, and production without editing the file or adding conditional logic.

Access control. Anyone who can read the filesystem can read every secret. There is no granularity. The database password sits right next to the Stripe API key, and both are visible to any code running in the PHP process.

Rotation difficulty. Changing a credential means editing a file, which means a deployment or manual file edit. On platforms without SSH access, this can require going through a dashboard file manager.

The platforms below each attempt to solve one or more of these problems. Some succeed better than others.

WordPress VIP: Enterprise-Grade Secret Management

WordPress VIP takes secrets management more seriously than any other managed WordPress host. Their approach separates application secrets from the codebase entirely, providing an API for both setting and retrieving secrets.

Setting Environment Variables with the VIP CLI

VIP uses their proprietary CLI tool to manage environment variables. You must install it first:

npm install -g @automattic/vip

To set an environment variable:

# Set a secret for production
vip config envvar set STRIPE_SECRET_KEY --app=my-site --env=production

# The CLI will prompt for the value interactively
# This prevents the value from appearing in shell history

To list existing variables (names only, never values):

vip config envvar list --app=my-site --env=production

To retrieve a variable’s value for debugging:

vip config envvar get STRIPE_SECRET_KEY --app=my-site --env=production

To remove a variable:

vip config envvar delete STRIPE_SECRET_KEY --app=my-site --env=production

Accessing Secrets in PHP

In your WordPress code, you never use getenv() or $_ENV on VIP. Instead, you use their dedicated class:

use Automattic\VIP\Environment;

// Retrieve a secret
$stripe_key = Environment::get_var( 'STRIPE_SECRET_KEY' );

// With a fallback default
$api_timeout = Environment::get_var( 'API_TIMEOUT', 30 );

// Check if a variable exists
if ( Environment::has_var( 'CUSTOM_CDN_URL' ) ) {
    $cdn_url = Environment::get_var( 'CUSTOM_CDN_URL' );
}

The Environment::get_var() method checks multiple sources in a defined priority order: environment variables set via the CLI take precedence, followed by constants defined in vip-config.php, and finally any defaults you specify.

VIP’s Limitations and Gotchas

The 16KB total limit. This is the single most important constraint to understand. VIP imposes a 16KB cap on the total size of all environment variables combined, not per variable, but across all of them. That sounds generous until you start storing JSON configuration blobs or long API tokens. A typical WordPress site with database credentials, payment gateway keys, email service credentials, analytics tokens, and a few third-party API keys can easily consume 2-3KB. Sites that integrate with multiple microservices or store OAuth tokens will hit this ceiling.

No binary values. Environment variables on VIP must be UTF-8 strings. If you need to store a private key file (like a Google Cloud service account JSON), you will need to base64-encode it, which increases the size by roughly 33% and eats into that 16KB budget faster.

Propagation delay. After setting or updating a variable via the CLI, it can take several minutes for the change to propagate across all application containers. Do not set a new database credential and immediately rotate the old one. Overlap them.

No programmatic API for setting values. You can only set environment variables through the VIP CLI. There is no REST API for automation. If you want to rotate secrets via a CI/CD pipeline, you need to install and authenticate the VIP CLI in your pipeline, which adds complexity.

VIP Best Practices

Keep vip-config.php for non-sensitive configuration (like enabling debug mode per environment) and use environment variables exclusively for secrets. Do not mix the two patterns. Also, use a naming convention with prefixes to organize your variables:

vip config envvar set STRIPE_LIVE_SECRET --app=my-site --env=production
vip config envvar set STRIPE_TEST_SECRET --app=my-site --env=staging
vip config envvar set SMTP_HOST --app=my-site --env=production
vip config envvar set SMTP_PASSWORD --app=my-site --env=production

Pantheon: The Terminus Secrets Manager

Pantheon has evolved its secrets management significantly over the past few years. The current approach uses the Terminus Secrets Manager plugin, which provides a proper secrets API rather than relying solely on wp-config.php.

Installing and Configuring Terminus Secrets Manager

First, install the Terminus plugin:

# Install the secrets manager plugin for Terminus
terminus self:plugin:install terminus-secrets-manager-plugin

Setting a secret:

# Set a runtime secret (available to PHP at runtime)
terminus secret:set my-site.live STRIPE_SECRET_KEY sk_live_abc123 --scope=runtime

# Set a secret scoped to the Integrated Composer build process
terminus secret:set my-site.live COMPOSER_AUTH '{"github-oauth":{"github.com":"ghp_token"}}' --scope=ic

# Set a secret available in both runtime and IC contexts
terminus secret:set my-site.live MY_TOKEN abc123 --scope=runtime,ic

Listing secrets:

terminus secret:list my-site.live

Deleting a secret:

terminus secret:delete my-site.live STRIPE_SECRET_KEY

Runtime vs. IC Scopes

Pantheon distinguishes between two scopes, and this distinction is important:

Runtime scope: The secret is available to PHP code during normal page requests and WP-CLI commands. This is what you want for API keys, database overrides, and service credentials.

IC (Integrated Composer) scope: The secret is only available during the Integrated Composer build process. Use this for private package repository credentials, GitHub tokens for private dependencies, and similar build-time secrets.

You can assign both scopes to a single secret if needed, but the principle of least privilege says you should only grant the scope that is actually required.

Accessing Secrets in PHP on Pantheon

For runtime secrets, Pantheon provides the pantheon_get_secret() function:

// Retrieve a secret at runtime
$stripe_key = pantheon_get_secret( 'STRIPE_SECRET_KEY' );

// Use it in your configuration
if ( $stripe_key ) {
    define( 'STRIPE_SECRET_KEY', $stripe_key );
}

For environment-specific configuration that is not sensitive, Pantheon still encourages using their environment detection in wp-config.php:

// Pantheon environment detection
if ( isset( $_ENV['PANTHEON_ENVIRONMENT'] ) ) {
    switch ( $_ENV['PANTHEON_ENVIRONMENT'] ) {
        case 'live':
            define( 'WP_DEBUG', false );
            define( 'WP_CACHE', true );
            break;
        case 'test':
            define( 'WP_DEBUG', true );
            break;
        case 'dev':
            define( 'WP_DEBUG', true );
            define( 'WP_DEBUG_LOG', true );
            break;
    }
}

Pantheon’s Multidev Complexity

Pantheon’s Multidev feature creates branch-specific environments, which adds a wrinkle to secrets management. Secrets set on the dev environment do not automatically propagate to Multidev environments. You need to set secrets for each Multidev separately, or use a deployment script:

#!/bin/bash
# Script to propagate secrets to a new Multidev
SITE="my-site"
MULTIDEV="feature-branch"

terminus secret:set "$SITE.$MULTIDEV" STRIPE_SECRET_KEY "$STRIPE_TEST_KEY" --scope=runtime
terminus secret:set "$SITE.$MULTIDEV" SMTP_PASSWORD "$SMTP_TEST_PASSWORD" --scope=runtime

This is both a strength and a weakness. It means you can have completely different secrets per branch environment (useful for testing different API integrations), but it also means more operational overhead when creating new environments.

WP Engine: The Split Personality

WP Engine’s secrets management story is a tale of two platforms. Their traditional managed WordPress hosting has one approach, while their headless platform (Atlas) has another.

Traditional WP Engine: wp-config.php Is Your Only Option

On traditional WP Engine hosting, environment variables for PHP must be set in wp-config.php. There is no CLI for managing secrets, no API, and no dashboard UI for environment variables. You edit the file directly through SFTP, SSH, or their dashboard file manager.

WP Engine does inject some of their own constants into the environment before your wp-config.php loads, which means certain values are pre-configured:

// These are already set by WP Engine's infrastructure
// DB_NAME, DB_USER, DB_PASSWORD, DB_HOST are handled by WP Engine
// Do NOT override them in wp-config.php

// Your custom secrets go here
define( 'STRIPE_SECRET_KEY', 'sk_live_...' );
define( 'MAILGUN_API_KEY', 'key-...' );
define( 'GOOGLE_MAPS_API_KEY', 'AIza...' );

Environment-Specific Configuration on WP Engine

WP Engine provides staging, development, and production environments. Each has its own wp-config.php. To manage environment-specific secrets, you use conditional logic based on WP Engine’s environment detection:

// Detect WP Engine environment
if ( defined( 'WPE_APIKEY' ) ) {
    // We're on WP Engine
    if ( getenv( 'IS_WPE_SNAPSHOT' ) ) {
        // Staging environment
        define( 'STRIPE_SECRET_KEY', 'sk_test_...' );
    } else {
        // Production environment
        define( 'STRIPE_SECRET_KEY', 'sk_live_...' );
    }
}

This pattern means your staging secrets live in plaintext in the same file as your production secrets. That is not ideal from a security standpoint.

WP Engine Atlas: Proper Environment Variables

Atlas, WP Engine’s headless WordPress platform, brings modern environment variable management. Since Atlas involves Node.js applications for the front end, they needed a proper env var system.

In the Atlas dashboard, you can set environment variables per environment through the UI. For the WordPress backend (which still runs PHP), you can also set environment variables in the Atlas dashboard that get injected at runtime.

For the Node.js front end, these work exactly like you would expect:

// In your Atlas Node.js application
const apiUrl = process.env.NEXT_PUBLIC_WORDPRESS_URL;
const secretKey = process.env.FAUST_SECRET_KEY;

The Atlas CLI also supports environment variable management:

# List environment variables
wpe alpha envs list --app=my-atlas-app --env=production

# Set an environment variable
wpe alpha envs set STRIPE_SECRET_KEY=sk_live_abc123 --app=my-atlas-app --env=production

The gap between traditional WP Engine and Atlas in this regard is striking. If secrets management is important to you and you are evaluating WP Engine, the Atlas platform is significantly more capable.

Kinsta: API-Driven Management with wp-config.php Defaults

Kinsta takes an interesting middle path. They manage the core WordPress database credentials automatically, but your custom secrets still go in wp-config.php. However, they also provide an API for certain management tasks.

Kinsta’s Default wp-config.php Handling

When Kinsta provisions a site, they generate a wp-config.php with database credentials, authentication salts, and their own performance constants. You should not modify the database constants they set, but you can add your own:

// Kinsta-managed (do not edit)
define( 'DB_NAME', 'kinsta_db_name' );
define( 'DB_USER', 'kinsta_db_user' );
define( 'DB_PASSWORD', 'auto_generated_password' );
define( 'DB_HOST', '127.0.0.1' );

// Your custom secrets (add below the Kinsta-managed section)
define( 'STRIPE_SECRET_KEY', 'sk_live_...' );
define( 'SENDGRID_API_KEY', 'SG...' );

Editing wp-config.php on Kinsta

Kinsta provides two methods for editing wp-config.php:

SSH access:

# SSH into your Kinsta site
ssh [email protected] -p 12345

# Edit wp-config.php
nano /www/my-site/public/wp-config.php

SFTP: Connect via SFTP using the credentials in the Kinsta dashboard, navigate to the public root, and edit the file directly.

Kinsta’s Environment Separation

Kinsta provides staging environments, and each staging environment gets its own wp-config.php. When you push from staging to production (or vice versa), the wp-config.php file is NOT overwritten; each environment maintains its own version. This is actually good design, because it means your staging secrets do not leak into production and vice versa.

The Kinsta API

Kinsta’s API allows programmatic management of sites, but at the time of this writing, it does not directly support setting custom environment variables or editing wp-config.php. The API is focused on site management operations:

# List sites
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
  "https://api.kinsta.com/v2/sites?company=YOUR_COMPANY_ID"

# Clear cache (useful after rotating secrets)
curl -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.kinsta.com/v2/sites/YOUR_SITE_ID/environments/YOUR_ENV_ID/clear-cache"

For automated secret rotation, you would need to combine the Kinsta API with SSH access to modify wp-config.php programmatically:

#!/bin/bash
# Rotate a secret on Kinsta via SSH
NEW_KEY=$(openssl rand -base64 32)

ssh [email protected] -p 12345 \
  "sed -i \"s/define( 'MY_SECRET_KEY', '.*' );/define( 'MY_SECRET_KEY', '$NEW_KEY' );/\" /www/my-site/public/wp-config.php"

This works but it is fragile. A misplaced quote or special character in the secret can break the sed command and corrupt your wp-config.php. Always back up the file first.

Cloudways: Server-Level vs. Application-Level

Cloudways adds an interesting dimension because they operate at two levels: the server (which can host multiple applications) and the individual application.

Server-Level Environment Variables

Cloudways allows you to set server-level environment variables through their platform dashboard under Server Management. These apply to all applications on the server. This is useful for shared credentials like a monitoring service API key.

However, Cloudways does not provide a CLI for managing environment variables directly. Management happens through the dashboard or their API.

Application-Level Configuration

For WordPress applications specifically, Cloudways uses the standard wp-config.php approach. You can access it through:

# SSH into your Cloudways server
ssh master_username@server_ip -p 22

# Navigate to your application
cd /home/master_username/applications/your_app/public_html

# Edit wp-config.php
nano wp-config.php

Cloudways also provides a built-in file manager in the dashboard, and an application settings panel where certain PHP settings can be configured. But custom environment variables still require editing wp-config.php.

Using the Cloudways API for Automation

The Cloudways API is more capable than many people realize. While it does not directly support setting environment variables, it does support SSH key management and server operations:

# Get OAuth token
TOKEN=$(curl -s -X POST "https://api.cloudways.com/api/v1/oauth/access_token" \
  -d "email=YOUR_EMAIL&api_key=YOUR_API_KEY" | jq -r '.access_token')

# List servers
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://api.cloudways.com/api/v1/server"

# List applications on a server
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://api.cloudways.com/api/v1/app/list?server_id=YOUR_SERVER_ID"

For secret rotation automation, you would combine the API for server operations with SSH for file editing. It is not as clean as a first-class secrets API, but it works.

Cloudways Environment Separation

Cloudways staging environments (available through their Staging Management feature or the SafeUpdates add-on) get cloned copies of wp-config.php. This means staging initially has the same secrets as production. You need to manually update the staging wp-config.php to use test credentials. Failing to do this is a common mistake that results in staging environments hitting production payment gateways or sending real emails.

Flywheel: Dashboard-Driven Configuration

Flywheel (now part of WP Engine) takes the most opinionated approach of any platform on this list: they heavily restrict direct file system access and push configuration through their dashboard.

wp-config.php Management on Flywheel

Flywheel manages wp-config.php for you. You cannot directly edit the main file. Instead, Flywheel loads a user-editable config file that gets included before the main WordPress bootstrap:

// Flywheel loads wp-config.php which includes:
// /path/to/your/site/wp-config.php (Flywheel-managed)
//   -> includes a user config file where you can add constants

To add custom constants, you use SFTP to create or edit the appropriate file. Flywheel provides SFTP credentials through their dashboard. The exact file location and name can vary, so consult their current documentation for your specific setup.

Flywheel’s Local Development Tool

One area where Flywheel stands out is Local (their local development application). Local provides a clean way to manage wp-config.php for development:

# Local stores site configs in a predictable location
# On macOS:
~/Local Sites/my-site/app/public/wp-config.php

# You can edit this freely for local development

The challenge is that there is no built-in mechanism to sync secrets between Local and the production Flywheel environment. You end up maintaining separate credential files manually.

Flywheel’s Limitations

Flywheel does not offer a CLI for secrets management. There is no API for setting environment variables. The dashboard does not have an environment variable management panel. Your options are SFTP-based editing and the dashboard file manager. For teams that need automated credential rotation or CI/CD-driven secret injection, Flywheel requires workarounds that other platforms handle natively.

The phpdotenv Approach: Taking Control

If your hosting platform’s native secrets management is insufficient, or if you want a consistent approach across multiple platforms, the vlucas/phpdotenv library provides a proven alternative. This is the approach used by Bedrock, the popular WordPress boilerplate from Roots.

Setting Up phpdotenv

Install via Composer:

composer require vlucas/phpdotenv

Create a .env file in your project root (above the web root):

# .env file - NEVER commit this to version control
DB_NAME=wordpress
DB_USER=wp_user
DB_PASSWORD=s3cretP@ssw0rd
DB_HOST=localhost

# WordPress salts
AUTH_KEY='generate-a-unique-phrase'
SECURE_AUTH_KEY='generate-another-unique-phrase'
LOGGED_IN_KEY='and-another-one'
NONCE_KEY='keep-going'
AUTH_SALT='almost-there'
SECURE_AUTH_SALT='one-more'
LOGGED_IN_SALT='penultimate'
NONCE_SALT='final-one'

# Custom secrets
STRIPE_SECRET_KEY=sk_live_abc123
STRIPE_PUBLISHABLE_KEY=pk_live_abc123
SMTP_PASSWORD=email_password_here

# Environment
WP_ENV=production
WP_DEBUG=false

Loading .env in wp-config.php

Modify your wp-config.php to load the .env file:

<?php
// Load Composer autoloader
require_once __DIR__ . '/vendor/autoload.php';

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

// Required variables - throws exception if missing
$dotenv->required([
    'DB_NAME',
    'DB_USER',
    'DB_PASSWORD',
    'DB_HOST',
]);

// Define WordPress constants from env vars
define( 'DB_NAME', $_ENV['DB_NAME'] );
define( 'DB_USER', $_ENV['DB_USER'] );
define( 'DB_PASSWORD', $_ENV['DB_PASSWORD'] );
define( 'DB_HOST', $_ENV['DB_HOST'] );

// Salts
define( 'AUTH_KEY', $_ENV['AUTH_KEY'] );
define( 'SECURE_AUTH_KEY', $_ENV['SECURE_AUTH_KEY'] );
define( 'LOGGED_IN_KEY', $_ENV['LOGGED_IN_KEY'] );
define( 'NONCE_KEY', $_ENV['NONCE_KEY'] );
define( 'AUTH_SALT', $_ENV['AUTH_SALT'] );
define( 'SECURE_AUTH_SALT', $_ENV['SECURE_AUTH_SALT'] );
define( 'LOGGED_IN_SALT', $_ENV['LOGGED_IN_SALT'] );
define( 'NONCE_SALT', $_ENV['NONCE_SALT'] );

// Custom secrets
define( 'STRIPE_SECRET_KEY', $_ENV['STRIPE_SECRET_KEY'] ?? '' );

// Environment-based config
$is_debug = filter_var( $_ENV['WP_DEBUG'] ?? false, FILTER_VALIDATE_BOOLEAN );
define( 'WP_DEBUG', $is_debug );

// ... rest of wp-config.php

The Bedrock Pattern

Bedrock takes phpdotenv further with a complete project restructuring:

project-root/
├── .env                  # Secrets (not committed)
├── .env.example          # Template (committed)
├── composer.json
├── config/
│   ├── application.php   # Main config (replaces wp-config.php)
│   └── environments/
│       ├── development.php
│       ├── staging.php
│       └── production.php
├── vendor/
└── web/                  # Document root
    ├── app/              # wp-content equivalent
    ├── wp/               # WordPress core (Composer-managed)
    └── index.php

The environment-specific config files in Bedrock let you set different behaviors per environment without conditionals in a single file:

// config/environments/development.php
Config::define( 'WP_DEBUG', true );
Config::define( 'WP_DEBUG_LOG', true );
Config::define( 'SCRIPT_DEBUG', true );

// config/environments/production.php
Config::define( 'WP_DEBUG', false );
Config::define( 'WP_CACHE', true );

phpdotenv Security Considerations

File placement matters. The .env file must live outside the document root. If it is inside the web root and your server is misconfigured (missing the .htaccess rule or nginx directive to block dotfiles), anyone can download your secrets by visiting https://yoursite.com/.env.

.gitignore is non-negotiable. Add .env to your .gitignore immediately. Add it before you create the file. Add it to a global gitignore as well:

# Add to .gitignore
.env
.env.*
!.env.example

Provide a template. Always commit a .env.example file with placeholder values so that new developers know which variables they need to configure:

# .env.example
DB_NAME=wordpress
DB_USER=root
DB_PASSWORD=
DB_HOST=localhost
STRIPE_SECRET_KEY=sk_test_REPLACE_ME
SMTP_PASSWORD=REPLACE_ME
WP_ENV=development
WP_DEBUG=true

Validate required variables. Use phpdotenv’s validation to fail early and loudly if a required variable is missing:

$dotenv->required('DB_PASSWORD')->notEmpty();
$dotenv->required('WP_ENV')->allowedValues(['development', 'staging', 'production']);
$dotenv->required('STRIPE_SECRET_KEY')->assert(function ($value) {
    return strpos($value, 'sk_') === 0;
}, 'STRIPE_SECRET_KEY must start with sk_');

Docker and Kubernetes: Infrastructure-Level Secrets

Self-hosted WordPress on Docker or Kubernetes gives you the most control over secrets management, but also the most responsibility. Let us cover both.

Docker Compose Environment Variables

The simplest Docker approach uses environment variables in docker-compose.yml:

version: '3.8'
services:
  wordpress:
    image: wordpress:6.4-php8.2
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wp_user
      WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
      STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
    ports:
      - "8080:80"
    depends_on:
      - db

  db:
    image: mariadb:10.4
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wp_user
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - db_data:/var/lib/mysql

volumes:
  db_data:

The ${VARIABLE} syntax references a .env file in the same directory as your docker-compose.yml:

# .env (Docker Compose reads this automatically)
DB_PASSWORD=s3cretP@ssw0rd
MYSQL_ROOT_PASSWORD=r00tP@ss
STRIPE_SECRET_KEY=sk_live_abc123

To use these in WordPress, you need a wp-config.php that reads from environment variables:

// wp-config.php for Docker
define( 'DB_NAME', getenv('WORDPRESS_DB_NAME') );
define( 'DB_USER', getenv('WORDPRESS_DB_USER') );
define( 'DB_PASSWORD', getenv('WORDPRESS_DB_PASSWORD') );
define( 'DB_HOST', getenv('WORDPRESS_DB_HOST') );

// Custom env vars
define( 'STRIPE_SECRET_KEY', getenv('STRIPE_SECRET_KEY') );

Docker Secrets

For production Docker Swarm deployments, Docker Secrets provide a more secure alternative to environment variables. Secrets are encrypted at rest and only mounted into containers that explicitly request them.

# Create a Docker secret
echo "sk_live_abc123" | docker secret create stripe_secret_key -

# Or from a file
docker secret create stripe_secret_key ./stripe_key.txt

# List secrets
docker secret ls

In your docker-compose.yml (Swarm mode):

version: '3.8'
services:
  wordpress:
    image: wordpress:6.4-php8.2
    secrets:
      - stripe_secret_key
      - db_password
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wp_user
      WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_password

secrets:
  stripe_secret_key:
    external: true
  db_password:
    external: true

Docker secrets are mounted as files at /run/secrets/secret_name. To read them in PHP:

// Reading Docker secrets in PHP
function get_docker_secret( $name ) {
    $file = "/run/secrets/{$name}";
    if ( file_exists( $file ) ) {
        return trim( file_get_contents( $file ) );
    }
    return null;
}

define( 'STRIPE_SECRET_KEY', get_docker_secret('stripe_secret_key') );
define( 'DB_PASSWORD', get_docker_secret('db_password') );

Kubernetes ConfigMaps and Secrets

Kubernetes provides two distinct resources for configuration: ConfigMaps for non-sensitive data and Secrets for sensitive data.

Create a Kubernetes Secret:

# Create secret from literal values
kubectl create secret generic wordpress-secrets \
  --from-literal=db-password='s3cretP@ssw0rd' \
  --from-literal=stripe-secret-key='sk_live_abc123' \
  --from-literal=smtp-password='email_pass'

# Or from a YAML manifest
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
  name: wordpress-secrets
type: Opaque
data:
  db-password: czNjcmV0UEBzc3cwcmQ=  # base64 encoded
  stripe-secret-key: c2tfbGl2ZV9hYmMxMjM=
EOF

Create a ConfigMap for non-sensitive config:

kubectl create configmap wordpress-config \
  --from-literal=db-host='mysql-service' \
  --from-literal=db-name='wordpress' \
  --from-literal=wp-debug='false'

Reference them in your Pod spec:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: wordpress
spec:
  replicas: 3
  selector:
    matchLabels:
      app: wordpress
  template:
    metadata:
      labels:
        app: wordpress
    spec:
      containers:
      - name: wordpress
        image: wordpress:6.4-php8.2
        env:
        - name: WORDPRESS_DB_HOST
          valueFrom:
            configMapKeyRef:
              name: wordpress-config
              key: db-host
        - name: WORDPRESS_DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: wordpress-secrets
              key: db-password
        - name: STRIPE_SECRET_KEY
          valueFrom:
            secretKeyRef:
              name: wordpress-secrets
              key: stripe-secret-key
        volumeMounts:
        - name: secrets-volume
          mountPath: /run/secrets
          readOnly: true
      volumes:
      - name: secrets-volume
        secret:
          secretName: wordpress-secrets

External Secrets Operators

For production Kubernetes clusters, consider using the External Secrets Operator to sync secrets from an external vault (AWS Secrets Manager, HashiCorp Vault, Google Secret Manager, Azure Key Vault) into Kubernetes Secrets:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: wordpress-secrets
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: ClusterSecretStore
  target:
    name: wordpress-secrets
  data:
  - secretKey: db-password
    remoteRef:
      key: production/wordpress/db-password
  - secretKey: stripe-secret-key
    remoteRef:
      key: production/wordpress/stripe-secret-key

This setup automatically rotates secrets when they change in the external vault. The External Secrets Operator polls the vault at the configured interval and updates the Kubernetes Secret. Your WordPress pods pick up the new values on restart or, with some additional configuration, via a sidecar that watches for changes.

Security Best Practices for WordPress Secrets

Regardless of which platform or approach you use, certain security principles apply universally.

Environment Variable Leakage Through Error Logs

PHP’s error handling can inadvertently log sensitive data. When an exception occurs in a function that receives a secret as a parameter, the stack trace includes the parameter values:

// DANGEROUS: Secret appears in stack trace if Stripe SDK throws
try {
    $stripe = new \Stripe\StripeClient( getenv('STRIPE_SECRET_KEY') );
    $stripe->charges->create([...]);
} catch ( \Exception $e ) {
    error_log( $e->getMessage() );
    // The full exception trace, including function parameters,
    // may include your Stripe key
}

The fix is to load secrets into variables early and configure error reporting to exclude parameter values:

// Better: Load secret once, use the variable
$stripe_key = getenv('STRIPE_SECRET_KEY');
$stripe = new \Stripe\StripeClient( $stripe_key );

// Best: Also configure PHP to not include args in stack traces
// In php.ini or at runtime:
// zend.exception_ignore_args = On (PHP 7.4+)

For PHP 7.4 and later, the zend.exception_ignore_args directive prevents function arguments from appearing in stack traces. Set this in your php.ini for production:

; php.ini production settings
zend.exception_ignore_args = On
display_errors = Off
log_errors = On
error_log = /path/to/php-error.log

The phpinfo() Problem

The phpinfo() function displays every environment variable in the “Environment” and “PHP Variables” sections. A forgotten phpinfo() page in production is a complete credential dump.

// NEVER do this in production
// info.php
<?php phpinfo();

// Any visitor to yoursite.com/info.php sees ALL secrets

Mitigations:

# Block phpinfo files at the server level (nginx)
location ~* /phpinfo\.php$ {
    deny all;
    return 404;
}

# Or in .htaccess (Apache)
<FilesMatch "phpinfo\.php$">
    Require all denied
</FilesMatch>

Also, disable the phpinfo function entirely in production:

; php.ini
disable_functions = phpinfo, exec, passthru, shell_exec, system, proc_open, popen

Process List Exposure

On shared hosting or multi-tenant environments, other users on the system can potentially see environment variables through the process list:

# This shows environment variables of running processes
ps auxwe

# Or through /proc on Linux
cat /proc/PID/environ

This is why Docker Secrets and Kubernetes Secrets (mounted as files) are more secure than environment variables. File-based secrets do not appear in process listings.

Secrets in WordPress Debug Logs

WordPress’s debug log (wp-content/debug.log) can accumulate sensitive data over time. Plugins that log HTTP requests might log authorization headers. Custom code that logs API calls might include credentials.

// DANGEROUS: Logging the full request including auth headers
function log_api_call( $url, $args ) {
    error_log( print_r( $args, true ) ); // Headers with API keys get logged
}

// SAFE: Redact sensitive headers before logging
function log_api_call_safe( $url, $args ) {
    $safe_args = $args;
    if ( isset( $safe_args['headers']['Authorization'] ) ) {
        $safe_args['headers']['Authorization'] = '[REDACTED]';
    }
    error_log( print_r( $safe_args, true ) );
}

On production, restrict access to debug.log:

# .htaccess in wp-content directory
<Files "debug.log">
    Require all denied
</Files>

And consider setting a custom log path outside the web root:

define( 'WP_DEBUG_LOG', '/var/log/wordpress/debug.log' );

Git History and Committed Secrets

If secrets were ever committed to your Git repository, removing them from the current branch is not enough. They persist in the Git history. You need to rewrite history:

# Using git-filter-repo (recommended over filter-branch)
pip install git-filter-repo

# Remove a specific file from all history
git filter-repo --path wp-config.php --invert-paths

# Or replace specific strings in all history
git filter-repo --replace-text expressions.txt

# Where expressions.txt contains:
# sk_live_abc123==>REDACTED
# s3cretP@ssw0rd==>REDACTED

After rewriting history, you must force-push and have all collaborators re-clone. Also, rotate every credential that was ever committed. The old values may exist in forks, local clones, CI/CD caches, and backup systems.

Migration Strategies: Moving Secrets Between Platforms

Moving a WordPress site between hosting platforms means moving secrets between entirely different management systems. A methodical approach prevents the kinds of outages that make for bad postmortems.

Step 1: Inventory All Secrets

Before migrating, create a complete inventory of every secret your site uses. Check these sources:

# Check wp-config.php for defined constants
grep "define(" wp-config.php | grep -v "^//" | grep -v "table_prefix"

# Check for .env files
find . -name ".env*" -not -path "*/node_modules/*" -not -path "*/vendor/*"

# Check for hardcoded API keys in theme and plugin files
grep -r "sk_live_\|sk_test_\|pk_live_\|pk_test_" wp-content/themes/ wp-content/plugins/

# Check wp_options for stored credentials
wp option list --search="*key*" --search="*secret*" --search="*password*" --search="*token*"

Build a spreadsheet with columns for: secret name, current value, source (wp-config, database, env var), which environments need it, and whether it needs to be rotated during migration.

Step 2: Set Up Secrets on the Destination Platform

Based on the destination platform, configure each secret:

# Example: Migrating to WordPress VIP
while IFS='=' read -r key value; do
    echo "Setting $key..."
    vip config envvar set "$key" --app=my-site --env=production <<< "$value"
done < secrets_export.txt

# Example: Migrating to Pantheon
while IFS='=' read -r key value; do
    echo "Setting $key..."
    terminus secret:set my-site.live "$key" "$value" --scope=runtime
done < secrets_export.txt

Step 3: Verify Before DNS Switch

Before pointing DNS to the new platform, verify that all secrets are correctly configured:

# Create a temporary verification script (REMOVE AFTER TESTING)
// verify-secrets.php - access via direct URL, delete immediately after
<?php
$required_secrets = [
    'DB_NAME',
    'DB_USER',
    'DB_PASSWORD',
    'STRIPE_SECRET_KEY',
    'SMTP_PASSWORD',
];

foreach ( $required_secrets as $secret ) {
    $value = defined( $secret ) ? constant( $secret ) : getenv( $secret );
    $status = ! empty( $value ) ? 'SET' : 'MISSING';
    $length = $value ? strlen( $value ) : 0;
    echo "{$secret}: {$status} (length: {$length})\n";
    // Never print the actual value
}

Step 4: Rotate After Migration

After the migration is complete and verified, rotate all secrets. The old platform still has copies. Even if you delete the old site, backups may retain the credentials:

# Generate new WordPress salts
curl -s https://api.wordpress.org/secret-key/1.1/salt/

# Rotate database password (coordinate with hosting platform)
# Rotate API keys in their respective dashboards (Stripe, SendGrid, etc.)
# Update the new platform with rotated credentials

Rotating Database Credentials Without Downtime

Database credential rotation is the most nerve-wracking secret rotation because getting it wrong means instant site downtime. Here is a zero-downtime approach.

The Dual-Credential Method

Most MySQL and MariaDB installations allow a user to authenticate with multiple credentials simultaneously. The approach:

Step 1: Create a second database user with the new password.

-- Connect to MySQL as root
CREATE USER 'wp_user_v2'@'%' IDENTIFIED BY 'new_s3cret_password';
GRANT ALL PRIVILEGES ON wordpress.* TO 'wp_user_v2'@'%';
FLUSH PRIVILEGES;

Step 2: Update WordPress to use the new credentials.

On platforms with environment variables:

# Update the environment variable
vip config envvar set DB_USER --app=my-site --env=production <<< "wp_user_v2"
vip config envvar set DB_PASSWORD --app=my-site --env=production <<< "new_s3cret_password"

On platforms using wp-config.php:

// Update wp-config.php
define( 'DB_USER', 'wp_user_v2' );
define( 'DB_PASSWORD', 'new_s3cret_password' );

Step 3: Verify the new credentials are working.

# Check site availability
curl -s -o /dev/null -w "%{http_code}" https://yoursite.com/

# Run a quick WP-CLI check
wp db check

Step 4: Remove the old user after a grace period (24-48 hours).

DROP USER 'wp_user'@'%';
FLUSH PRIVILEGES;

The grace period accounts for any cached connections, long-running cron jobs, or background processes that might still be using the old credentials.

The ALTER USER Method

If creating a second user is not practical, MySQL 5.7+ and MariaDB 10.2+ support changing a user's password while existing connections remain active:

-- Change password (existing connections keep working until they reconnect)
ALTER USER 'wp_user'@'%' IDENTIFIED BY 'new_s3cret_password';
FLUSH PRIVILEGES;

Then quickly update WordPress's configuration to use the new password. The race condition here is that any new connection attempt between the password change and the config update will fail. On a high-traffic site, this window can cause errors.

To minimize the race condition:

#!/bin/bash
# Atomic-ish credential rotation script

# Step 1: Change MySQL password
mysql -u root -p -e "ALTER USER 'wp_user'@'%' IDENTIFIED BY 'new_password'; FLUSH PRIVILEGES;"

# Step 2: Immediately update wp-config.php
sed -i "s/define( 'DB_PASSWORD', '.*' );/define( 'DB_PASSWORD', 'new_password' );/" /path/to/wp-config.php

# Step 3: Clear any object cache that might cache db connections
wp cache flush 2>/dev/null || true

echo "Credential rotation complete."

Platform Comparison Table

Here is a side-by-side comparison of secrets management capabilities across all platforms covered in this guide:

Feature WordPress VIP Pantheon WP Engine Kinsta Cloudways Flywheel Self-Hosted (Docker/K8s)
Dedicated secrets API Yes Yes Atlas only No No No Yes (K8s Secrets, Docker Secrets)
CLI management vip CLI Terminus wpe CLI (Atlas) SSH only SSH only SFTP only kubectl / docker
Per-environment secrets Yes Yes Partial Yes Manual Manual Yes
Encrypted at rest Yes Yes Unknown No (file-based) No (file-based) No (file-based) Yes (K8s with encryption)
Access control granularity High Medium Low Low Low Low High (RBAC)
Audit logging Yes Limited No No No No Yes (with setup)
Automated rotation support Via CLI scripting Via Terminus scripting Manual Via SSH scripting Via SSH scripting Manual External Secrets Operator
Size/count limits 16KB total No published limit N/A N/A N/A N/A 1MB per Secret (K8s)
Build-time vs. runtime separation No Yes (IC vs. runtime scopes) No No No No Yes (multi-stage builds)
External vault integration No No No No No No Yes (ESO, Vault Agent)

Building a Universal Secrets Abstraction Layer

If you manage WordPress sites across multiple platforms, maintaining different secrets management approaches per platform becomes painful. Here is a PHP abstraction layer that normalizes access across platforms:

<?php
/**
 * Universal secrets manager for WordPress.
 * Checks multiple sources in priority order.
 */
class WPKite_Secrets_Manager {

    private static $cache = [];

    /**
     * Retrieve a secret from the best available source.
     *
     * Priority order:
     * 1. PHP constant (defined in wp-config.php)
     * 2. Environment variable
     * 3. Docker secret file
     * 4. WordPress VIP Environment class
     * 5. Pantheon secrets
     * 6. WordPress options table (last resort, not recommended for sensitive data)
     *
     * @param string $key     The secret name.
     * @param mixed  $default Default value if not found.
     * @return mixed
     */
    public static function get( $key, $default = null ) {
        // Check cache first
        if ( isset( self::$cache[ $key ] ) ) {
            return self::$cache[ $key ];
        }

        $value = null;

        // 1. PHP constant
        if ( defined( $key ) ) {
            $value = constant( $key );
        }

        // 2. Environment variable
        if ( $value === null ) {
            $env_value = getenv( $key );
            if ( $env_value !== false ) {
                $value = $env_value;
            }
        }

        // 3. Docker secret file
        if ( $value === null ) {
            $secret_file = '/run/secrets/' . strtolower( $key );
            if ( file_exists( $secret_file ) ) {
                $value = trim( file_get_contents( $secret_file ) );
            }
        }

        // 4. WordPress VIP
        if ( $value === null && class_exists( 'Automattic\VIP\Environment' ) ) {
            $value = \Automattic\VIP\Environment::get_var( $key );
        }

        // 5. Pantheon
        if ( $value === null && function_exists( 'pantheon_get_secret' ) ) {
            $value = pantheon_get_secret( $key );
        }

        // 6. WordPress options (not recommended for true secrets)
        if ( $value === null && function_exists( 'get_option' ) ) {
            $option_value = get_option( 'secret_' . strtolower( $key ) );
            if ( $option_value !== false ) {
                $value = $option_value;
            }
        }

        // Apply default
        if ( $value === null ) {
            $value = $default;
        }

        // Cache the result
        self::$cache[ $key ] = $value;

        return $value;
    }

    /**
     * Check if a secret exists in any source.
     */
    public static function has( $key ) {
        return self::get( $key ) !== null;
    }

    /**
     * Clear the internal cache (useful for testing).
     */
    public static function flush_cache() {
        self::$cache = [];
    }
}

Usage across any platform:

// Works on VIP, Pantheon, Docker, or traditional hosting
$stripe_key = WPKite_Secrets_Manager::get( 'STRIPE_SECRET_KEY' );
$db_password = WPKite_Secrets_Manager::get( 'DB_PASSWORD' );
$api_timeout = WPKite_Secrets_Manager::get( 'API_TIMEOUT', 30 );

This abstraction means your plugin or theme code does not need to know which platform it is running on. The secrets manager checks each source in priority order and returns the first non-null value.

CI/CD Pipeline Integration

Modern WordPress development involves CI/CD pipelines that need access to secrets for testing, building, and deploying. Each CI platform has its own secrets management.

GitHub Actions

Store secrets in GitHub repository settings, then reference them in workflows:

# .github/workflows/deploy.yml
name: Deploy to Production
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to WordPress VIP
        env:
          VIP_TOKEN: ${{ secrets.VIP_TOKEN }}
        run: |
          npm install -g @automattic/vip
          vip app deploy --app=my-site --env=production

      - name: Run integration tests
        env:
          STRIPE_TEST_KEY: ${{ secrets.STRIPE_TEST_KEY }}
          TEST_DB_PASSWORD: ${{ secrets.TEST_DB_PASSWORD }}
        run: |
          composer install
          vendor/bin/phpunit --testsuite=integration

GitLab CI

GitLab CI/CD variables can be scoped to specific environments:

# .gitlab-ci.yml
deploy_production:
  stage: deploy
  environment:
    name: production
  variables:
    DEPLOY_TARGET: "production"
  script:
    - echo "Deploying with production secrets..."
    - terminus secret:set my-site.live STRIPE_KEY "$STRIPE_LIVE_KEY" --scope=runtime
  only:
    - main

Securing CI/CD Secrets

Several rules for CI/CD secret hygiene:

Never echo secrets in logs. It is tempting to add echo $SECRET for debugging. Most CI platforms mask known secret values in logs, but do not rely on this.

# BAD: Secret may appear in logs
echo "Using key: $STRIPE_SECRET_KEY"

# GOOD: Verify without exposing
echo "Stripe key length: ${#STRIPE_SECRET_KEY}"
echo "Stripe key prefix: ${STRIPE_SECRET_KEY:0:7}..."

Limit secret scope. Only expose secrets to the jobs that need them. Do not make every secret available to every job in your pipeline.

Use OIDC where possible. GitHub Actions and GitLab CI both support OpenID Connect for assuming cloud provider roles without storing long-lived credentials. For AWS deployments:

# GitHub Actions with OIDC (no AWS keys stored)
- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789:role/deploy-role
    aws-region: us-east-1

Handling Secrets in WordPress Multisite

WordPress Multisite installations add another layer of complexity. Some secrets are network-wide (database credentials, core salts), while others are site-specific (individual Stripe accounts per site, per-site SMTP credentials).

Network-Level vs. Site-Level Secrets

// Network-level secrets go in wp-config.php or environment variables
define( 'DB_PASSWORD', getenv('DB_PASSWORD') );

// Site-level secrets can use the options table with encryption
function wpkite_get_site_secret( $key ) {
    $encrypted = get_option( "encrypted_{$key}" );
    if ( ! $encrypted ) {
        return null;
    }

    // Decrypt using a master key from environment
    $master_key = getenv( 'ENCRYPTION_MASTER_KEY' );
    if ( ! $master_key ) {
        return null;
    }

    $nonce = base64_decode( $encrypted['nonce'] );
    $ciphertext = base64_decode( $encrypted['ciphertext'] );

    return sodium_crypto_secretbox_open( $ciphertext, $nonce, $master_key );
}

function wpkite_set_site_secret( $key, $value ) {
    $master_key = getenv( 'ENCRYPTION_MASTER_KEY' );
    if ( ! $master_key ) {
        return false;
    }

    $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
    $ciphertext = sodium_crypto_secretbox( $value, $nonce, $master_key );

    return update_option( "encrypted_{$key}", [
        'nonce'      => base64_encode( $nonce ),
        'ciphertext' => base64_encode( $ciphertext ),
    ] );
}

This pattern stores encrypted secrets in the WordPress options table, using a master encryption key from the environment. Each site in the multisite network can have its own encrypted secrets while sharing a single master key.

Monitoring and Alerting for Secret Exposure

Prevention is important, but detection matters too. Set up monitoring to catch accidental secret exposure.

Git Pre-Commit Hooks

Use tools like gitleaks or trufflehog to scan commits for secrets before they reach the repository:

# Install gitleaks
brew install gitleaks

# Run as a pre-commit hook
# .git/hooks/pre-commit
#!/bin/bash
gitleaks protect --staged --verbose
if [ $? -ne 0 ]; then
    echo "Secrets detected in staged changes. Commit blocked."
    exit 1
fi

Or use a .pre-commit-config.yaml with the pre-commit framework:

repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks

Runtime Monitoring

Add a WordPress health check that verifies secrets are not exposed through common vectors:

// Add to a custom plugin or theme functions
add_filter( 'site_status_tests', function( $tests ) {
    $tests['direct']['secrets_exposure'] = [
        'label' => 'Secrets exposure check',
        'test'  => 'wpkite_test_secrets_exposure',
    ];
    return $tests;
} );

function wpkite_test_secrets_exposure() {
    $result = [
        'label'       => 'No secrets exposure detected',
        'status'      => 'good',
        'badge'       => [ 'label' => 'Security', 'color' => 'blue' ],
        'description' => 'Common secret exposure vectors were checked.',
        'test'        => 'secrets_exposure',
    ];

    // Check for phpinfo files in web root
    $web_root = ABSPATH;
    $dangerous_files = glob( $web_root . '*info*.php' );
    if ( ! empty( $dangerous_files ) ) {
        $result['status'] = 'critical';
        $result['label']  = 'Potential phpinfo() file detected in web root';
    }

    // Check if debug.log is web-accessible
    $debug_log = WP_CONTENT_DIR . '/debug.log';
    if ( file_exists( $debug_log ) ) {
        $response = wp_remote_get( content_url( 'debug.log' ) );
        if ( wp_remote_retrieve_response_code( $response ) === 200 ) {
            $result['status'] = 'critical';
            $result['label']  = 'debug.log is publicly accessible';
        }
    }

    // Check if .env file exists in web root
    if ( file_exists( $web_root . '.env' ) ) {
        $result['status'] = 'critical';
        $result['label']  = '.env file found in web root';
    }

    return $result;
}

Advanced Pattern: HashiCorp Vault Integration

For organizations that need centralized secrets management across multiple WordPress installations, HashiCorp Vault provides a mature solution. Here is how to integrate it with WordPress.

Setting Up the Vault Client

composer require mittwald/vault-php
<?php
use Mittwald\Vault\Client;
use Mittwald\Vault\Authentication\Token;

class WPKite_Vault_Integration {

    private $client;
    private $cache = [];
    private $cache_ttl = 300; // 5 minutes

    public function __construct() {
        $vault_addr  = getenv( 'VAULT_ADDR' ) ?: 'https://vault.example.com:8200';
        $vault_token = getenv( 'VAULT_TOKEN' );

        if ( ! $vault_token ) {
            return;
        }

        $auth = new Token( $vault_token );
        $this->client = new Client( $vault_addr, $auth );
    }

    public function get_secret( $path, $key ) {
        $cache_key = "{$path}/{$key}";

        if ( isset( $this->cache[ $cache_key ] ) ) {
            $cached = $this->cache[ $cache_key ];
            if ( $cached['expires'] > time() ) {
                return $cached['value'];
            }
        }

        try {
            $secret = $this->client->getSecret( $path );
            $data = $secret->getData();

            if ( isset( $data[ $key ] ) ) {
                $this->cache[ $cache_key ] = [
                    'value'   => $data[ $key ],
                    'expires' => time() + $this->cache_ttl,
                ];
                return $data[ $key ];
            }
        } catch ( \Exception $e ) {
            error_log( "Vault error for {$path}/{$key}: " . $e->getMessage() );
        }

        return null;
    }
}

// Usage
$vault = new WPKite_Vault_Integration();
$stripe_key = $vault->get_secret( 'secret/wordpress/production', 'stripe_secret_key' );

This approach centralizes all secrets in Vault, supports automatic rotation, provides audit logging, and works across every hosting platform that allows outbound HTTPS connections.

Common Mistakes and How to Avoid Them

After years of managing WordPress secrets across platforms, certain mistakes keep recurring. Here are the ones I see most frequently.

Mistake 1: Storing Secrets in the Database Without Encryption

Many plugins store API keys in the wp_options table as plaintext. This means anyone with database access (or a SQL injection vulnerability) can read every API key:

// BAD: Plaintext secret in options
update_option( 'my_plugin_api_key', 'sk_live_abc123' );

// BETTER: Encrypt before storing
$encrypted = wpkite_encrypt( 'sk_live_abc123' );
update_option( 'my_plugin_api_key_encrypted', $encrypted );

Mistake 2: Using the Same Secrets Across Environments

Using production Stripe keys in staging means test orders process real payments. Using production SMTP credentials in development means test emails go to real customers. Always maintain separate credentials per environment.

Mistake 3: Not Rotating Secrets After Team Changes

When a team member leaves or a contractor's engagement ends, rotate every secret they had access to. This includes database passwords, API keys, SSH keys, and deployment tokens. It is tedious. It is necessary.

Mistake 4: Hardcoding Secrets in Theme or Plugin Files

This happens more often than anyone wants to admit:

// Found in a theme's functions.php (real example, values changed)
$stripe = new \Stripe\StripeClient( 'sk_live_real_key_was_here' );

If this file is in a Git repository (and it usually is), the secret is now in the repository history forever.

Mistake 5: Ignoring Backup Security

Your database backups contain whatever secrets are stored in wp_options. Your file backups contain wp-config.php with all its credentials. Treat backups with the same security rigor as the production system. Encrypt backups at rest and control access to them.

Putting It All Together: A Migration Checklist

Whether you are migrating between platforms or setting up a new WordPress installation, use this checklist to ensure your secrets management is solid:

Before deployment:

  • Inventory all secrets (database, API keys, salts, SMTP, third-party services).
  • Determine the target platform's secrets management capabilities.
  • Set up per-environment secrets (development, staging, production).
  • Add .env and wp-config.php to .gitignore if using file-based secrets.
  • Create a .env.example template for team members.
  • Install pre-commit hooks to scan for accidentally committed secrets.

During deployment:

  • Configure secrets on the target platform using the platform's preferred method.
  • Verify all secrets are set and accessible before switching traffic.
  • Test critical integrations (payment processing, email delivery, third-party APIs).

After deployment:

  • Rotate all secrets that existed on the old platform.
  • Verify that the old platform no longer has access to current credentials.
  • Set up monitoring for secret exposure (gitleaks, runtime checks).
  • Document the secrets management approach for your team.
  • Schedule regular secret rotation (quarterly at minimum for critical credentials).

Final Thoughts

The gap between the best and worst secrets management in the WordPress hosting ecosystem is enormous. WordPress VIP and Pantheon offer dedicated APIs and encrypted storage. WP Engine's traditional hosting still relies on editing PHP files. Self-hosted Kubernetes deployments can leverage enterprise-grade tools like External Secrets Operator and HashiCorp Vault.

The right choice depends on your security requirements, team size, and operational complexity. A solo developer running a blog does not need Kubernetes Secrets. An agency managing 200 client sites on WP Engine needs a systematic approach even though the platform itself does not provide one natively.

Whatever platform you are on, the fundamentals remain the same: keep secrets out of version control, use different credentials per environment, rotate regularly, monitor for exposure, and encrypt at rest when possible. The tools and commands differ. The principles do not.

If you take one thing from this guide, let it be this: treating secrets management as an infrastructure concern rather than an afterthought will save you from the kind of security incident that makes the news.

Share this article

Marcus Chen

Staff engineer with 12 years in WordPress infrastructure. Previously at Automattic and a large media company. Writes about hosting platforms, caching, and deployment pipelines.