A standardized secrets management API for WordPress. Provides get_secret() and set_secret() — the missing secrets API that WordPress has always needed. All secrets are encrypted at rest. Always.
Every WordPress plugin that connects to an external service stores API keys, tokens, and credentials in the wp_options table — in plaintext. There is no standard API for secrets management. Google Site Kit, WooCommerce, Mailchimp, Yoast, and hundreds of other plugins each reinvent their own (usually insecure) approach.
Displace Secrets Manager solves this by providing a single, extensible API where encryption is the only option.
// Store a secret
set_secret( 'my-plugin/api_key', $api_key );
// Retrieve a secret
$api_key = get_secret( 'my-plugin/api_key' );
// Check existence
if ( secret_exists( 'my-plugin/api_key' ) ) {
// ...
}
// Delete a secret
delete_secret( 'my-plugin/api_key' );That's it. Encryption, key management, and backend selection are handled automatically.
Just activate the plugin. Secrets are encrypted immediately using keys derived from your existing WordPress salts — no configuration required.
For dedicated key management, add to wp-config.php:
// Generate with: wp secret generate-key
define( 'WP_SECRETS_KEY', 'your-generated-key-here' );If you want to use an environment variable, wrap it yourself:
define( 'WP_SECRETS_KEY', getenv( 'MY_SECRETS_KEY' ) );Displace Secrets Manager uses a three-layer architecture:
- Consumer Layer — Global functions (
get_secret(),set_secret()) and WP-CLI commands. - SDK / Public API — The
Secretsclass that enforces access control, validates keys, fires audit hooks, and delegates to the active provider. - Provider Layer — Pluggable backends that store secrets. Ships with one built-in encrypted provider; supports third-party backends via the provider interface.
Secrets use a two-tier encryption scheme:
- A secrets key (derived from
WP_SECRETS_KEYorLOGGED_IN_KEY . LOGGED_IN_SALT) encrypts the master key. - A randomly-generated master key (stored encrypted in
wp_options) encrypts individual secrets.
This means key rotation (wp secret rotate) only re-encrypts the single master key — not every stored secret.
The secrets key is derived from one of two sources:
| Priority | Source | How |
|---|---|---|
| 1 | WP_SECRETS_KEY constant |
Defined in wp-config.php (recommended) |
| 2 | WordPress salts | LOGGED_IN_KEY . LOGGED_IN_SALT (always available) |
There is no plaintext fallback. Encryption is always active.
The plugin ships with one built-in provider (encrypted options) and supports unlimited third-party providers. The highest-priority available provider is selected automatically. Power users can force a specific provider:
define( 'WP_SECRETS_PROVIDER', 'aws-kms' );Displace Secrets Manager supports seamless key rotation via WP_SECRETS_KEY_PREVIOUS:
- Set
WP_SECRETS_KEY_PREVIOUSto your current key. - Set
WP_SECRETS_KEYto your new key. - Run
wp secret rotate. - Remove
WP_SECRETS_KEY_PREVIOUSonce done.
Because of the master key architecture, only the master key is re-encrypted — individual secrets are untouched. The provider also auto-heals: if it fails to decrypt the master key with the current key but succeeds with the previous key, it transparently re-encrypts the master key.
Secrets use a plugin-slug/secret-name convention to prevent collisions:
woocommerce/stripe_secret_key
wp-mail-smtp/sendgrid_api_key
my-plugin/oauth_token
Keys without a namespace are rejected by default. Use the --global flag in WP-CLI for site-wide infrastructure secrets.
# Store a secret
wp secret set my-plugin/api_key sk_live_abc123
# Store from stdin (avoids shell history exposure)
echo "sk_live_abc123" | wp secret set my-plugin/api_key --stdin
# Retrieve (masked by default)
wp secret get my-plugin/api_key
# Output: sk_l********************
# Retrieve with full value
wp secret get my-plugin/api_key --reveal
# Check existence (exit code 0 = exists, 1 = missing)
wp secret exists my-plugin/api_key
# List all keys (values never shown)
wp secret list
wp secret list --prefix=stripe/ --format=json
# Delete
wp secret delete my-plugin/api_key
# Show provider info
wp secret provider
# Migrate to a different provider
wp secret migrate --from=encrypted-options --to=aws-kms
# Re-encrypt master key after key rotation
wp secret rotate
# Export keys for documentation/audit
wp secret export-keys --format=csv
# Generate an encryption key
wp secret generate-keyThe SDK enforces namespace-based access control:
- Own namespace — A plugin can always read/write secrets in its own namespace (
my-plugin/*). - Cross-namespace — Requires the
manage_secretscapability (granted to administrators by default). - CLI — WP-CLI commands bypass namespace restrictions (shell access implies trust).
Customize access with the secrets_access filter:
add_filter( 'secrets_access', function( bool $allowed, string $key, string $operation, array $context ): bool {
if ( $context['plugin'] === 'my-monitor' && $operation === 'exists' ) {
return true;
}
return $allowed;
}, 10, 4 );| Hook | Parameters | Description |
|---|---|---|
secrets_register_providers |
— | Fire to register third-party providers |
secrets_provider_registered |
$id, $provider |
After a provider is registered |
secrets_provider_selected |
$id, $method |
After the active provider is chosen |
secrets_accessed |
$key, $operation, $context |
Every secret operation |
secrets_get |
$key, $context |
After a get operation |
secrets_set |
$key, $context |
After a set operation (value NOT passed) |
secrets_delete |
$key, $context |
After a delete operation |
secrets_exists |
$key, $context |
After an exists check |
secrets_list |
$key, $context |
After a list operation |
secrets_post_set |
$key, $context |
After successful storage |
secrets_post_delete |
$key, $result, $context |
After deletion attempt |
secrets_access_denied |
$key, $operation, $context |
When access is denied |
secrets_master_key_rotated |
$key_source |
When the master key is re-encrypted after rotation |
secrets_admin_page_before |
$providers, $active_id |
Before admin page render |
secrets_admin_page_after |
$providers, $active_id |
After admin page render |
| Filter | Parameters | Description |
|---|---|---|
secrets_provider |
$provider_id, $key, $context |
Override which provider handles a specific key |
secrets_pre_get |
$value, $key, $context |
Short-circuit get (return non-null to bypass provider) |
secrets_pre_set |
$value, $key, $context |
Modify value before storage |
secrets_access |
$allowed, $key, $operation, $context |
Override access control decisions |
Third-party providers implement the Secrets_Provider interface and register during secrets_register_providers:
<?php
/**
* Plugin Name: Secrets — AWS KMS Provider
* Requires Plugins: displace-secrets-manager
*/
add_action( 'secrets_register_providers', function() {
displace_secrets_register_provider( new My_KMS_Provider() );
});
class My_KMS_Provider implements Secrets_Provider {
public function get_id(): string {
return 'aws-kms';
}
public function get_name(): string {
return 'AWS Key Management Service';
}
public function get_priority(): int {
return 80; // Higher than encrypted-options (50)
}
public function is_available(): bool {
return class_exists( 'Aws\Kms\KmsClient' )
&& defined( 'WP_SECRETS_KMS_KEY_ID' );
}
// ... implement remaining interface methods
}Support both secrets-managed and traditional sites:
function my_plugin_get_api_key(): string {
if ( function_exists( 'get_secret' ) ) {
$key = get_secret( 'my-plugin/api_key' );
if ( null !== $key ) {
return $key;
}
}
return get_option( 'my_plugin_api_key', '' );
}
function my_plugin_set_api_key( string $value ): void {
if ( function_exists( 'set_secret' ) ) {
set_secret( 'my-plugin/api_key', $value );
delete_option( 'my_plugin_api_key' );
return;
}
update_option( 'my_plugin_api_key', $value, false );
}Displace Secrets Manager adds checks to Tools > Site Health:
- Recommended — Using key derived from WordPress salts; suggests defining
WP_SECRETS_KEY - Good — Encrypted provider active with dedicated key
- Good — Third-party provider active and healthy
Debug information (key source, master key status, provider details) is available in the Site Health Info tab.
What it protects against:
- Database exfiltration (SQL injection, backup theft, compromised dev copies)
- Unauthorized cross-plugin access to credentials
- Accidental secret exposure in logs and admin UI
Known limitations (documented honestly):
- If an attacker has both database AND filesystem access (wp-config.php), local encryption is defeated. Remote providers (KMS, Vault) mitigate this.
- The
debug_backtrace()-based caller detection is defense-in-depth, not a hard security boundary. This matches WordPress's existing trust model where plugins share a PHP process.
- PHP 7.2+ (uses libsodium via PHP's native extension or WordPress's sodium_compat)
- WordPress 6.9+
- WP-CLI 2.8+ (for CLI commands)
Copyright (c) 2026 Eric Mann. Licensed under GPL-2.0-or-later.