WordPress has no first-class way to store a credential. This proposal outlines a Secrets API An API or Application Programming Interface is a software intermediary that allows programs to interact with each other and share data in limited, clearly defined ways. for 7.2, along with WP-CLI WP-CLI is the Command Line Interface for WordPress, used to do administrative and development tasks in a programmatic way. The project page is http://wp-cli.org/ https://make.wordpress.org/cli/ support for it, and explains why the accompanying UI User interface should wait for 7.3.
Why it matters
Every plugin A plugin is a piece of software containing a group of functions that can be added to a WordPress website. They can extend functionality or add new features to your WordPress websites. WordPress plugins are written in the PHP programming language and integrate seamlessly with WordPress. These can be free in the WordPress.org Plugin Directory https://wordpress.org/plugins/ or can be cost-based plugin from a third-party. that needs an API key today writes it to the options table in plaintext. That is the only available path, so this isn’t a criticism of plugin authors. The consequence is that credentials end up in the same table as the site tagline, and therefore in every database dump, every backup, every staging clone, and every wp option get in a shared terminal.
Plugins have been solving this independently for years, each with its own key derivation, its own cipher choice, and its own failure modes. Site Kit encrypts its Google credentials. WooCommerce gateways handle payment keys. SMTP plugins hold mail credentials. Each one is a separate audit surface, and none of them can be reviewed once on behalf of the ecosystem.
This was survivable when the average site held a Mailchimp key and a reCAPTCHA secret. AI service integrations have made credentials both more numerous and far more expensive to lose: a leaked model-provider key is a metered spend liability. The blast radius increased while the storage mechanism stayed the same.
A second gap is that site owners and hosts increasingly need to answer basic operational questions — which credentials exist on this site, what code is using them, when were they last rotated. None of those are answerable today, because there is no object to ask about. A credential is just a row in a key-value store.
Core Core is the set of software required to run WordPress. The Core Development Team builds WordPress. has no concept of a secret; there’s nothing for either problem to attach to.
Clarifying priorities
A tightly-scoped, stable API landed in 7.2 helps push forward on requirements like those documented in the Security Audit for the Connectors screen.
No new UI in 7.2. The storage and retrieval semantics are the part that must be right the first time, because every plugin, every CLI Command Line Interface. Terminal (Bash) in Mac, Command Prompt in Windows, or WP-CLI for WordPress. command, and every future admin (and super admin) screen inherits them. A settings screen can be designed properly in 7.3 once there’s a stable API to build against and real usage to design from. The hooks In WordPress theme and development, hooks are functions that can be applied to an action or a Filter in WordPress. Actions are functions performed when a certain event occurs in WordPress. Filters allow you to modify certain functions. Arguments used to hook both filters and actions look the same. and accessors an admin screen would need are in scope now; the screen itself is not.
WP-CLI is in scope. It gives the API a first-party consumer inside the same release, which is the most reliable way to discover whether the surface is actually usable before it freezes. It also closes a real leak of its own: commands that accept credentials as arguments expose them in shell history and in the process list on shared hosts. Reading and writing through the API fixes that in the same change.
A feature plugin A plugin that was created with the intention of eventually being proposed for inclusion in WordPress Core. See Features as Plugins ships before the core patch A special text file that describes changes to code, by identifying the files and lines which are added, removed, and altered. It may also be referred to as a diff. A patch can be applied to a codebase for testing.. Rather than propose an API and ask people to evaluate it from a design document, I’ll publish the implementation as a plugin first. This will let contributors run the API instead of reading about it, expose the awkward parts of the surface while they’re still inexpensive to change, and give sites on 7.0 and 7.1 something usable immediately rather than waiting for December. The core patch then follows with an API surface identical to the plugin’s; nothing anyone builds against it needs rewriting.
The plugin will prove out the API before we consolidate things into core.
Proposed API
A small function set, a value object, and always-on encryption.
wp_set_secret( string $name, string $value ): bool|WP_Error
wp_get_secret( string $name, string $version = WP_Secret_Version::CURRENT ): WP_Secret|null|WP_Error
wp_delete_secret( string $name ): bool|WP_Error
wp_import_option_as_secret( string $option, string $name ): bool|WP_Error
WP_Secret::reveal(): string
WP_Secret::fingerprint(): string
At a call site:
wp_set_secret( 'my-plugin/api-key', $key_from_form );
$secret = wp_get_secret( 'my-plugin/api-key' );
if ( is_wp_error( $secret ) ) {
// Exists, but unusable. Key material changed. Tell the user.
} elseif ( null === $secret ) {
// Doesn't exist. Show the connect flow.
} else {
$client = new API_Client( $secret->reveal() );
}
Supporting surface:
- Secrets are namespaced by convention,
plugin-slug/secret-name, so a future admin screen can group by owner and cross-namespace access has something to check against.
WP_Secret masks itself in logs, var_dump(), and error output. Getting the raw string requires an explicit ->reveal(), which makes every point of use greppable.
- Default storage is the options API — ciphertext blobs,
autoload=no, excluded from options.php and the REST settings endpoint. The same relationship the object cache has to options: the default substrate, replaceable by a drop-in.
- Two capabilities A capability is permission to perform one or more types of task. Checking if a user has a capability is performed by the
current_user_can function. Each user of a WordPress site might have some permissions but not others, depending on their role. For example, users who have the Author role usually have permission to edit their own posts (the “edit_posts” capability), but not permission to edit other users’ posts (the “edit_others_posts” capability)., manage_secrets and manage_network_secrets.
- A change hook fires on write, carrying actor, timestamp, and old and new fingerprints. Fingerprints are readable; values are not.
Design decisions most likely to draw objections follow, with rationale.
Encryption is not optional
Secrets are encrypted at rest under a master-key envelope: a per-secret data key, wrapped by a master key. There is no plaintext mode and no constant to disable encryption.
The obvious objection is data loss — a site rotates its salts or migrates hosts, key material changes, and the secrets no longer decrypt. Plaintext options never do that. With an envelope, individual secrets are encrypted under a random master key, and the site key only wraps that master key. Rotation re-wraps a single value (the master key) without touching stored secrets. A dedicated constant is the preferred key source, with a zero-config fallback derived from existing salts for sites that can’t set one. When key material really is gone, the failure is bounded and visible: Site Health reports undecryptable secrets, and the recovery path is to re-enter them. Nothing is silently corrupted.
Site Kit is the empirical case. It derives from LOGGED_IN_KEY and LOGGED_IN_SALT by default, recommends a dedicated constant, and documents that salt rotation breaks stored credentials — and it has shipped that way at enormous scale. The ecosystem’s response was to document the approach, not to reject it.
Rationale: an API that can be configured to store plaintext is an options API with a misleading name. Making encryption unconditional is what lets a plugin author make a claim to their users about how the key is stored. The alternative — encryption off unless a site opts in — protects the small number of sites that rotate salts by leaving everyone else in plaintext forever. Sites that would opt in are largely the sites already capable of running a drop-in. Sites that would not are exactly the ones the API exists for.
Availability of cryptographic APIs is not a concern: libsodium has been bundled with PHP The web scripting language in which WordPress is primarily architected. WordPress requires PHP 7.4 or higher since 7.2, and core additionally ships sodium_compat for the cases where the extension has been disabled at build time. The primitives are present on every supported configuration without a new dependency.
Retrieval returns one of three things
wp_get_secret() returns a WP_Secret on success, null when the secret does not exist, and WP_Error when it exists but could not be retrieved — wrong key, unavailable keyring, failed decryption.
Rationale: collapsing “absent” and “broken” into a single false-y value is how you get a site that erroneously re-runs its onboarding flow, or disables an integration, when the real problem is key management. Separating them is also what lets a site distinguish a missing credential from an unauthorized change to key material.
No filter Filters are one of the two types of Hooks https://codex.wordpress.org/Plugin_API/Hooks. They provide a way for functions to modify data of other functions. They are the counterpart to Actions. Unlike Actions, filters are meant to work in an isolated manner, and should never have side effects such as affecting global variables and output. on the retrieval path
There is no filter applied to a secret on its way out of storage. No endpoint, filter, or capability A capability is permission to perform one or more types of task. Checking if a user has a capability is performed by the current_user_can function. Each user of a WordPress site might have some permissions but not others, depending on their role. For example, users who have the Author role usually have permission to edit their own posts (the “edit_posts” capability), but not permission to edit other users’ posts (the “edit_others_posts” capability). returns a secret’s value other than the API call itself.
Rationale: I expect the most pushback here — a filter on secret retrieval is, by construction, a hook that receives every credential on the site in plaintext. Any plugin could register it, making it a documented interception point. A filter that can intercept a credential is a filter that can steal one. The flexibility WordPress normally gets from filters is provided instead through explicit, replaceable providers, described next.
Two extension points, independently replaceable
A drop-in, secrets.php, exposes storage and keyring as separate extension points: where ciphertext is stored (Vault, Parameter Store, a host API) and what wraps the master key (a KMS, an HSM). Neither is ever handed a plaintext secret, and neither can turn encryption off.
Rationale: these are different concerns and sites have different constraints. Replacing only the keyring while keeping default storage is a reasonable configuration, and so is the inverse. Coupling them into one swap would force an all-or-nothing decision that most sites can’t make, and would push people back to storing credentials in options.
Two version slots, not unbounded history
CURRENT and PREVIOUS, modeled as string constants on a final class WP_Secret_Version rather than a native enum. Overwriting a secret keeps the old value recoverable, so a mistyped key is fixable. Requests already in flight won’t fail mid-rotation. Retiring the previous slot is an explicit operator action — no timers, no cron.
Rationale: named version history would keep every credential a site has ever held recoverable from a backup indefinitely. Two slots covers the real use case. Retirement is operator-driven because core has no way to know when a third-party integration has finished draining the old value; a timer would just guess wrong on a schedule. Native enums require PHP 8.1, and while 8.3 or better is the recommended version, core’s minimum remains 7.4 — so string constants on a final class are the portable equivalent.
Multisite Used to describe a WordPress installation with a network of multiple blogs, grouped by sites. This installation type has shared users tables, and creates separate database tables for each blog (wp_posts becomes wp_0_posts). See also network, blog, site is in v0
Network (versus site, blog)-level secrets are supported. Salts are network-wide, so distinct option rows would give logical separation and no cryptographic separation; instead a network root key derives per-site subkeys via sodium_crypto_kdf_derive_from_key(). Site secrets and network secrets are separate functions with separate capabilities, and there is no implicit fallback from one to the other.
Rationale: adding this later would mean introducing a second key hierarchy alongside the first, and migrating between them. The useful consequence of doing it now: rotating the key on a 500-site network re-wraps one value rather than 500.
Import, don’t migrate
No automatic sweep of the options table. wp_import_option_as_secret() lets a plugin author move one known option deliberately, on their own explicit upgrade schedule — and flags the imported secret for rotation rather than merely reporting success.
Rationale: core cannot reliably tell which options are credentials, and guessing would break sites in ways close to undebuggable. The rotation flag matters just as much: a credential that has been sitting in wp_options is already in backups, replicas, and object caches, and encrypting it now does not change that. Rotating is what actually fixes it.
Other decisions worth stating
- Strings in, strings out. The API does not serialize. Nothing stops a plugin from
json_encode()-ing something and storing the result, but core neither encourages it nor unpacks it, so a secret can never expand into an object graph on read.
- Fail closed. If an external store or key backend is unreachable, reads and writes return an error. There is no fallback to local storage or local key wrapping.
- Nothing plaintext enters the object cache. On shared hosting a persistent object cache is shared infrastructure.
WP_Secret refuses serialization outright.
- No export. Values are write-only. Migrations and staging pushes mean re-entry at the destination, so staging never holds production credentials. Fingerprints let an admin confirm the re-entered value matches.
What this does and does not do
The API is a hardening interface. If a secret lives in the WordPress database and a function exists to decrypt it, then any code running as WordPress can obtain that secret.
In scope: database dumps and exfiltration, backups sitting in cloud storage, SQL injection reading wp_options, careless disclosure surfaces — options.php, export files, support screenshots, screen shares, debug logs — and a compromised read-only replica.
Out of scope: code execution in the WordPress process. Nothing defends against that. Code running inside WordPress can call wp_get_secret() for the same reason it can read environment variables or wp-config.php — the secret has to be usable, so anything that is WordPress can use it. Registering a secret against a plugin slug does not prevent a different plugin from asking for it; slugs aren’t authenticated, and even a perfect allowlist wouldn’t stop code that reads the revealed value out of memory after a legitimate call. This proposal makes no claim of per-plugin isolation, and masked values are hygiene against shoulder-surfing rather than a privilege boundary.
What changes is the value of a stolen database, which is the overwhelmingly more common breach.
What it does provide:
- Credentials are not sitting in plaintext in a table that gets dumped, cloned, and shared.
- Access has one chokepoint, which makes it loggable and auditable for the first time.
- Rotation and retirement are defined operations rather than an
UPDATE and a hope.
- The storage extension point lets a host or site owner move secrets out of WordPress — which is the only configuration where real per-caller enforcement becomes possible.
Prior art
Timeline
Beta 1 for 7.2 is October 20–22, which is the practical deadline for an API landing in this release. Working backward:
- Now through mid-September: feedback on this proposal, particularly the questions below. Feature plugin published for testing.
- Late September: patch on Trac An open source project by Edgewall Software that serves as a bug tracker and project management tool for WordPress., with the API surface matching the plugin.
- Before Beta A pre-release of software that is given out to a large group of users to trial under real conditions. Beta versions have gone through alpha testing in-house and are generally fairly close in look, feel and function to the final product; however, design changes often occur as part of the process. 1: committed, or explicitly deferred to 7.3 rather than rushed in.
I’ve volunteered for technical contributions to 7.2 and intend to do this work rather than propose it and hand it off.
Feedback wanted
Objections now, while the surface is still cheap to change. Specifically:
- The no-filter decision on retrieval. Providers are meant to cover what a filter would normally give you — is that substitution sufficient for the cases you’d actually need to hook?
- Two version slots. Is
CURRENT/PREVIOUS sufficient, or is there a rotation pattern that genuinely needs more?
- Import, not migrate. Plugin authors: does
wp_import_option_as_secret() fit how you would actually move an existing key, or does it need a different shape?
- WP-CLI surface. Which commands most need this, and in what order?
- Hosts. If you run a real secret store or a real key backend and the drop-in surface is missing something you’d need, that’s the most useful feedback available — and much easier to add before anything ships than after.
Any feedback is welcome — please share your thoughts in the comments below.
Props to @jeffpaul, who talked me into building the original proof of concept six months ago, and @whyisjake for shepherding this proposal.
#proposal
You must be logged in to post a comment.