Repository navigation
Releases: ericmann/secrets-api
Release list
v0.3.0 — native types, and a breaking change for drop-ins
Released 2026-10-09. More from review on the core patch,
#66187. This release breaks a provider, store, or
keyring written against 0.2.3: see "If you have written a drop-in" below.
Changed
- Parameters and return values carry native PHP 7.4 types. This covers the public functions,
the three interfaces and everything that implements them, the internal classes, the plugin-only
code, and the WP-CLI command. A return that needs a union type, such as
WP_Secret|null|WP_Error, stays in the docblock. See
ADR 0013. - A wrong argument type is no longer reported as a
WP_Error.null, an array, or an object
throws aTypeError. A scalar is converted, sowp_get_secret( 123 )looks up the name'123'.
The runtime checks the declarations replace are removed. - A plaintext value stays untyped, and is still checked.
wp_set_secret()with a value that is
not a string returnsWP_SECRETS_ERROR_INVALID_VALUEas before. Declared asstring, PHP would
turnfalseinto an empty secret and report success. - Static types are narrower where that holds. A version or slot is one of the two
WP_Secret_Versionconstants, a scope is'site'|'network', a protection boundary is one of the
BOUNDARY_*constants, a Site Health status is one of its three values, and a count is
int<0, max>. A secret's name staysstring. - Comments and messages no longer use
--as a dash. Comments use an en dash. Three messages
were reworded: the Site Health description for secrets that cannot be decrypted, the key source
label for a legacyWP_SECRETS_KEY, and the legacy reader's unwrap error.
Fixed
- Ten docblock continuation lines were indented one column too far.
If you have written a drop-in
The interfaces now declare return types, and PHP refuses a class that implements an interface
without a compatible one. That is a fatal error at class load, and it cannot be caught. Add these
before upgrading:
| Interface | Method | Return type |
|---|---|---|
WP_Secrets_Provider |
get_label() |
string |
WP_Secrets_Provider |
get_protection_boundary() |
string |
WP_Secrets_Provider |
is_writable() |
bool |
WP_Secrets_Keyring |
get_key_source() |
string |
Parameter types are optional in an implementation, since PHP lets it leave its own parameters
untyped. The three examples and the reference drop-in show the
change.
Full changelog: https://secretsapi.dev/changelog/0-3-0/
v0.2.3 — the change hook reports scope, and src/ passes no text domain
Released 2026-10-07. More from review on the core patch,
#66187. The public functions have the same
signatures as 0.2.2. The wp_secret_changed action gains an argument, which matters to
anyone who has written a provider.
Changed
wp_secret_changedpasses$networkas a seventh argument. It is true for a network-scope
secret and false for a site-scope one. Before this, a listener could not tell a network secret
from a site secret of the same name. A listener registered for six arguments or fewer is
unaffected. See ADR 0012.- A provider is now tested for firing the hook. The API does not fire
wp_secret_changedon a
provider's behalf, so a drop-in that left it out silenced a site's audit log without anything
noticing.WP_Secrets_Provider_Conformancenow checks a write, an overridden action, a delete,
a retired version, a network-scope change, and a refused write. The interface states the
requirement ondelete()andretire_previous()as well asset(). src/passes no text domain to translation functions. Core leaves the argument off for its
own strings instead of naming'default'. The strings are unchanged.
Fixed
- The AWS Secrets Manager example did not fire
wp_secret_changedon delete. The new
conformance tests found it.
If you have written a provider
Add (bool) $network as the last argument of each do_action( 'wp_secret_changed', ... ) call,
and run the conformance suite. Both example providers show the change.
Examples
- The one translated string in the examples uses the
secrets-apitext domain. The examples are
not part of the core patch, so they do not share core's.
Changelog: https://secretsapi.dev/changelog/0-2-3/
v0.2.2 — PHPStan level 10, and the secrets/ subfolder
Released 2026-10-07. Both changes come from review on the core patch,
#66187. The public API has the same signatures and
the same behavior as 0.2.1, and the core patch carries the same src/.
Changed
- Everything passes PHPStan at level 10. A reviewer ran the core patch through PHPStan at its
strictest level and reported 85 findings insrc/and 400 in the tests.src/,plugin/,cli/,
and the plugin bootstrap are now clean at level 10, andmake analyseruns there instead of at
level 5. Most of the fixes are docblocks that now say what an array holds. A few are code:- A decrypt result is checked with
is_string()rather than againstfalse, so a result that is
neither is treated as a failure. - The request's cached root key is only served once it is known to be set.
- Demoting a slot keeps
createdonly when it is an integer, and storesneeds_rotationas a
boolean. - A stored option name that is not a string is skipped when listing.
- A decrypt result is checked with
- The tests are typed too. They are checked at level 10 inside wordpress-develop, where the
WordPress test classes are available. A test that passes a wrong type on purpose, to prove the
runtime check behind it, says so on that line. The error-code test is now one case per constant,
which is why the suite reports six more tests. - The 13 class and interface files live in
src/wp-includes/secrets/, followingabilities-api/
andai-client/in core. The functions file stays atsrc/wp-includes/secrets.php. Nothing was
renamed, and a drop-in is unaffected.
Fixed
wp secret set --stdinstored an empty secret when STDIN could not be read. It now stops with
an error.- A WP-CLI option given without its value, such as a bare
--format, was passed along as
true. It is now treated as not given.
Documentation
- New pages for the platform examples and for host-managed keys.
- The CI reference describes the static analysis setup.
Changelog: https://secretsapi.dev/changelog/0-2-2/
v0.2.1 — tests run clean on PHP 8.5
Released 2026-09-25. A test-only release: nothing under src/, plugin/, or cli/ changed, and the
API behaves exactly as in 0.2.0.
Fixed
- The tests failed on PHP 8.5. PHP 8.5 deprecates
ReflectionMethod::setAccessible()and
ReflectionProperty::setAccessible(), which have done nothing since PHP 8.1. The test suite
treats deprecations as errors, so the handful of tests that reach a private member through
reflection errored. Those calls now run only whenPHP_VERSION_ID < 80100, the same guard core's
own tests use. The core patch on #66187 carries
the same fix. Its PHP 8.5 CI jobs are what found this.
Changed
- CI adds PHP 8.5 to the test matrix and to the WP-CLI smoke test, so the plugin's CI tops out
where core's does. See the CI reference.
Changelog: https://secretsapi.dev/changelog/0-2-1/
v0.2.0 — KMS and Vault examples, CLI smoke test, multisite root-key fix
Released 2026-09-25. This is the last release before the Trac patch
(ADR 0008). The public API
has the same signatures as 0.1.0. Two changes under src/ go into the patch: the root-key cache
and the multisite fix below.
Added
-
examples/aws-kms-keyring/, the first realWP_Secrets_Keyring. It is a single-file SigV4
drop-in that moves custody of the root key to AWS KMS. Verified against live KMS. -
examples/vault-provider/, a HashiCorp Vault KV v2 provider. It caps each secret at two
versions, andPREVIOUSis strictly the version before the current one, so retiring a version
never brings back an older one
(ADR 0010). Verified against
Vault 2.1.1, including a sealed server, and against OpenBao 2.7.0. -
wp secret rotate --from=config-previous|configre-wraps the root key under the active
keyring.--from=configis how an existing site moves onto a KMS keyring. -
WP_Secrets_Keyring_Conformance, the keyring counterpart of the provider conformance suite. -
make test-examplesand anexamplesCI job. They run the three examples against Moto and
a Vault dev server, on single site and multisite, including an automated conformance run for
the AWS Secrets Manager example. -
make smoke, a WP-CLI smoke test that drives a real, pinnedwpbinary against a
throwaway install. It checks:- every subcommand and flag
- the 0/1/2 exit-code contract
- site-key rotation from end to end
- drop-in loading through the real loader
- a pass after converting to multisite
It is part of
make ci, and it runs in asmokeCI job on PHP 7.4 and 8.3.
Changed
WP_Secrets_Key_Managerkeeps the unwrapped root key for the rest of the request, tied to the
stored wrapped value. With a remote keyring, that means one call per request instead of one per
secret. A rotation or re-wrap replaces the cached key, and an unwrap error is never cached
(ADR 0009).- The
WP_Secrets_Keyring::wrap()docblock now requireswrap()to be non-deterministic.
rotate_site_key()already relied on it. - Breaking for anyone using the AWS Secrets Manager example: site-scope secrets now live at
wp/site/<blog_id>/<name>instead ofwp/<name>. See the example's README for the upgrade.
Fixed
- Converting a single site to multisite made every secret unreadable. The wrapped root key
stayed inwp_optionswhile reads moved towp_sitemeta, so the first read after conversion
generated a new root key. The key manager now moves the key across instead. - The AWS Secrets Manager example shared one AWS secret across every site on a network.
- The AWS Secrets Manager example reported
has_previousas false for every secret, and for a
minute or two after a delete it reported the secret as unreachable instead of absent. make smokefailed on a host without wp-env, because WP-CLI ran out of memory at PHP's default
128M limit.- The recorded cause of the old
--version=previousbug was wrong. It was wp-env's argument
parser, not WP-CLI.
Security
- The docs site's
sharpdependency was raised to 0.35.4, for libvips and libheif advisories.
The plugin itself was not affected.
Documentation
Full documentation: https://secrets-api-docs.view.fast/ · Changelog: https://secrets-api-docs.view.fast/changelog/0-2-0/
v0.1.0 — Secrets API feature plugin
First public release of the feature plugin implementing the Secrets API proposed for WordPress 7.2.
This is a working implementation, not a prototype. Everything under src/ is written to be copied into wordpress-develop unchanged: same paths, core coding standards, the default text domain, @since 7.2.0, and a PHP 7.4 syntax floor.
What it does
Encrypted, versioned credential storage, with a key hierarchy in which exactly one value is ever stored wrapped:
site key → root key → master key → data key → secret value
The root key is the only wrapped value on the site. Master keys are derived per scope on demand and never stored, data keys are per secret and per slot. Rotating the site key re-wraps one value, whether the install has one site or five hundred.
Encryption is sodium_crypto_aead_xchacha20poly1305_ietf_* throughout, with key derivation via sodium_crypto_kdf_derive_from_key(). Every ciphertext is bound by AAD to its purpose, scope, site, name, and slot, so a record cannot be replayed under any of them.
Two version slots
Every secret has a CURRENT and a PREVIOUS slot. Rotate the credential at the provider, deploy, let in-flight requests drain against the old value, then retire it explicitly. There are no timers and no cron; retirement is an operator action.
Three states that never collapse
wp_get_secret() returns a WP_Secret, null when the secret does not exist, or a WP_Error when it exists but could not be produced. Never a bare false.
This is the property most of the design exists to protect. An unreachable backend reported as "absent" is how somebody talks themselves into regenerating a credential they still had.
API surface
Functions
wp_get_secret() · wp_set_secret() · wp_delete_secret() · wp_list_secrets() · wp_retire_secret_version() · wp_import_option_as_secret()
Each has a network-scope counterpart: wp_get_network_secret(), wp_set_network_secret(), wp_delete_network_secret(), wp_list_network_secrets(), wp_retire_network_secret_version(). Network and site scope use separate capabilities and separate storage prefixes, with no implicit fallback between them.
Also wp_secrets_validate_name(), wp_using_secrets_dropin(), wp_secrets_provider_label(), wp_secrets_provider_is_writable(), and wp_secrets_memzero().
Classes and interfaces
WP_Secret · WP_Secret_Version · WP_Secrets_Provider · WP_Secrets_Store · WP_Secrets_Keyring · WP_Secrets_Libsodium_Provider · WP_Secrets_Option_Store · WP_Secrets_Config_Key_Provider · WP_Secrets_Cipher · WP_Secrets_Key_Manager, plus WP_Secrets_Broken_Provider / _Store / _Keyring as fail-closed sentinels.
One action, no filters
wp_secret_changed fires on every change, with $name, $action (created, updated, deleted, imported, retired), $actor_id, $timestamp, $old_fingerprint, $new_fingerprint. Fingerprints only; never a value.
There are zero apply_filters() calls in src/, and an architectural test enforces that. Nothing may observe or alter a credential between storage and caller.
Ten error codes, from WP_SECRETS_ERROR_INVALID_NAME through WP_SECRETS_ERROR_PROVIDER_READ_ONLY, so a caller can tell a bad argument from an unreachable keyring from a record written by a newer format version.
The provider model
Three hosts said in the proposal comments that the published extension contract could not express their deployments. They were right, and the resolution was a reframe rather than an exception.
The proposal said that neither drop-in extension point "is ever handed a plaintext secret." That sentence did two jobs, and only the first one mattered. Encryption cannot be turned off is non-negotiable. The store never sees plaintext was one way of achieving it, rather than the thing itself. As Rafael Meneses put it: a provider can be stronger than the default, never weaker.
So WP_Secrets_Provider is the outermost extension point, and WP_Secrets_Libsodium_Provider is one implementation of it rather than the privileged case. A KMS, an HSM, or a control panel implements the same interface.
Routing is declared, never inferred. A wp-content/secrets.php drop-in sets one of three globals:
| Global | Replaces | Effort |
|---|---|---|
wp_secrets_provider |
Everything; the platform owns the secret | 8 methods |
wp_secrets_keyring |
How the root key is wrapped | 3 methods |
wp_secrets_store |
Where records live | 4 methods |
Setting none of them is supported and common. A drop-in that swaps only the keyring gets the shipped provider built around that keyring, which is what most hosts actually want.
A drop-in that throws, or sets any of those globals to something that does not implement the matching interface, fails closed: every operation returns WP_Error through a Broken_* sentinel. It never silently reverts to the default, because a site whose platform integration just broke must not start answering from a different credential store and report its credentials missing.
examples/aws-secrets-manager/ contains a working AWS Secrets Manager drop-in: about 300 lines, one SigV4 signature, no SDK. Secrets Manager's AWSCURRENT and AWSPREVIOUS staging labels map directly onto WP_Secret_Version::CURRENT and ::PREVIOUS, so the two-slot model needed no emulation.
WP-CLI
wp secret get · set · delete · list · rotate · retire · health · dropin · generate-key · import-option · migrate-legacy, plus wp network-secret for network scope.
Reads are masked by default, to a fixed eight-asterisk width so the mask does not leak length. --reveal warns before printing. wp secret list has no flag that prints a value.
Site Health
Three tests: whether the site key is a dedicated constant or a weaker fallback, whether every stored secret still decrypts, and whether any secrets are flagged as needing rotation. Debug info reports counts and class names only, never values or fingerprints.
Requirements
- PHP 7.4 or later
- WordPress 6.6 or later
- libsodium, via the extension or core's bundled
sodium_compat
Once core ships the API, the plugin detects it, stands down, and says so in an admin notice rather than shadowing core's implementation.
Coexisting with the Displace prototype
Some plugins were built against an earlier prototype. There is no compatibility shim. What exists instead: a read for a secret that only exists in the prototype's format is upgraded into the current format on first read and flagged needs_rotation. The prototype's own rows are never modified or deleted, and an architectural test enforces that. wp secret migrate-legacy does the same thing in bulk, additively. There is no delete step anywhere.
Deliberately not included
- No per-plugin isolation. Namespacing groups secrets by owner. Any plugin that can run PHP can read any secret. Masking is hygiene, not a privilege boundary.
- No admin settings screen. The proposal defers it to 7.3. The hooks and accessors one would need are here; the screen is not.
- No export.
WP_Secret::reveal()is the only path to a plaintext. Migrations mean re-entry at the destination. - Drop-ins are fully trusted code.
get_protection_boundary()andis_writable()are declarations for humans and Site Health, never enforcement, and their docblocks say so.
Testing
455 tests, 1,023 assertions single-site and 1,035 multisite. CI runs PHP 7.4 / 8.0 / 8.3 against WordPress latest and trunk, plus a multisite job, with static analysis (phpcs, PHPCompatibilityWP at 7.4, PHPStan) gating the matrix. Every GitHub Action is pinned by full commit SHA.
WP_Secrets_Provider_Conformance is an abstract suite you can extend to check your own provider for the properties implements cannot verify.
Known gaps are recorded honestly in docs/open-questions.md, including that CLI dispatch has no automated coverage and that sodium_compat is never exercised by the suite.
What this release is asking for
Not "does this look right." The provider interface is shaped by three hosts describing what they need, not by anyone having built against it. The first real implementation will turn up something those descriptions missed.
Implement WP_Secrets_Provider against a real platform and report what broke. See CONTRIBUTING.md. Security issues go through SECURITY.md, never a public issue.