Displace Secrets Manager archived
DEPRECATED — superseded by github.com/ericmann/secrets-api, the Secrets API feature plugin proposed for WordPress 7.2.
by Eric Mann · github.com/ericmann/displace-secrets-manager · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/ericmann/displace-secrets-manager/archive/refs/heads/main.zipReadme
[!IMPORTANT]
Deprecated — superseded by the Secrets API feature plugin
This repository is archived and no longer maintained. Development continues at ericmann/secrets-api, which implements the Secrets API proposed for WordPress 7.2 and is written to be merged into core.
This project was a prototype. It proved the idea was worth doing and shaped the proposal, but its API is not the one going into WordPress.
Switching is not a drop-in swap — budget real work for it
The replacement deliberately ships no compatibility shim. Nothing here keeps working unchanged:
- Every function is renamed and re-shaped.
get_secret()→wp_get_secret(),set_secret()→wp_set_secret(),delete_secret()→wp_delete_secret(). There is nosecret_exists(). The$contextarray parameter has no equivalent.- The return contract changed, and this is the one that will bite you.
get_secret()returned?string, collapsing "no such secret" and "could not decrypt it" into the samenull.wp_get_secret()returns aWP_Secret,nullonly when the secret genuinely does not exist, or aWP_Errorwhen it exists and could not be produced. Call->reveal()for the value and checkis_wp_error(). Porting a call site that treatednullas "not set" without handlingWP_Erroris how a working credential gets regenerated during an outage.- Exceptions are gone. The new API returns
WP_Errorand never throws.- No admin screen. The proposal defers UI to 7.3.
- No per-plugin isolation, and there never really was. Namespacing groups secrets by owner; it is not an access-control boundary. The new project's documentation states this plainly rather than implying otherwise.
Your stored secrets are readable, but check which case you are in
The new plugin reads this plugin's on-disk format, including records sealed under
WP_SECRETS_KEY, underWP_SECRETS_KEY_PREVIOUS, or under theLOGGED_IN_KEY/LOGGED_IN_SALTfallback.
- Unnamespaced keys (
api_key) upgrade automatically. The firstwp_get_secret( 'api_key' )decrypts the old row, rewrites it in the new format, flags it as needing rotation, and leaves the old row untouched.- Namespaced keys (
my-plugin/api_key, which this plugin's own docs recommended) do not upgrade automatically.wp_get_secret( 'my-plugin/api_key' )returnsnull, which is indistinguishable from never having set it. Runwp secret migrate-legacyfor these — it handles them correctly, and--dry-runshows you what it would do first.This plugin's option rows are never modified or deleted by the new one, so both can run on the same site while you port. Cleaning up afterwards is a manual
wp option delete.Start with the new project's README and its migration notes. Security issues belong in the new repository's private advisory form.
Displace Secrets Manager
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.
The Problem
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.
Quick Start
For Plugin Developers
// 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.
For Site Operators
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' ) );
How It Works
Architecture
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.
Master Key Architecture
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.
Key Derivation
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.
Automatic Provider Selection
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' );
Key Rotation
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.
Key Namespacing
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.
WP-CLI Commands
# 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-key
Access Control
The 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 );
Hooks Reference
Actions
| 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 |
Filters
| 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 |
Writing a Custom Provider
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
}
Adopting Displace Secrets Manager in Your Plugin
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 );
}
Site Health Integration
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.
Security Considerations
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.
Requirements
- 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)
License
Copyright (c) 2026 Eric Mann. Licensed under GPL-2.0-or-later.