Secrets and Environment Variable Management Across WordPress Hosting Platforms
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
.envandwp-config.phpto.gitignoreif using file-based secrets. - Create a
.env.exampletemplate 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.
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.