WordPress Secrets API: I Found 56 Keys in Plaintext
On 25 August, Eric Mann published a proposal on Make WordPress Core for a Secrets API in WordPress 7.2: four functions, a WP_Secret object, and encryption that cannot be switched off. The opening line of the case for it is the one every plugin author already knows: WordPress has no first-class way to store a credential, so every plugin that needs an API key writes it to the options table in plaintext, where it rides along in every database dump, every backup and every staging clone.
The proposal does not put a number on that. It cannot; nobody has counted across the ecosystem. But I can count one site. So before the feedback window closes in mid-September and the patch either lands before Beta 1 on 20–22 October or slips to 7.3, I audited the wp_options table of this one: a WordPress 7.1 install with 48 active plugins, a WooCommerce store, an AI chatbot that talks to seven model vendors, and six months of ordinary operation behind it. The question was simple. If the Secrets API had shipped in 7.1 instead of being proposed for 7.2, what would it have had to protect?
The answer is 56 plaintext credentials, 36 of them loaded into memory on every page request, 11 of them belonging to plugins that are no longer installed, and 48 of them byte-for-byte identical to a database dump that has been sitting in the home directory since 3 March. That last number is the one the proposal is really about.
What the Secrets API proposal actually says
The proposal is long and worth reading in full, but the parts a site owner needs fit in a table. Everything here is from the 25 August post and its comment thread, read on 12 September.
| Piece | What it does | In 7.2? |
|---|---|---|
wp_set_secret( $name, $value ) | Encrypts and stores a string. Strings in, strings out; no arrays, no objects. | Yes |
wp_get_secret( $name ) | Returns a WP_Secret, null if absent, or WP_Error if present but undecryptable. Three states, so a wrong key does not look like a missing key. | Yes |
WP_Secret::reveal() | The only way to get the plaintext. The object masks itself in var_dump() and logs, and refuses serialization so it cannot land in a persistent object cache. | Yes |
wp_import_option_as_secret( $option, $name ) | Moves an existing plaintext option into secret storage and flags it for rotation. There is no automatic sweep of the options table. | Yes |
| Storage | Still the options table, as ciphertext, with autoload = no, excluded from options.php and the REST settings endpoint. | Yes |
| Keys | Envelope encryption on libsodium. Master key from a dedicated constant if defined, otherwise derived from the existing LOGGED_IN_KEY and LOGGED_IN_SALT. | Yes |
| Plaintext mode | None. No constant disables encryption. | — |
| WP-CLI commands | So a key never has to pass through a shell history or a process list on shared hosting. | Yes |
| Drop-in providers | A secrets.php drop-in can replace where ciphertext lives and what wraps the master key (Vault, Parameter Store, KMS). Neither provider ever receives plaintext. | Yes |
| Admin UI | A settings screen for viewing and rotating secrets. | No, 7.3 |
Two design choices matter more than the rest. The first is that there is no filter on the retrieval path, because, as the proposal puts it, a filter on secret retrieval is by construction a hook that receives every credential on the site in plaintext. The second is the threat model, which the proposal states plainly rather than overselling: this does not protect you from code running inside WordPress. Anything that can call get_option() can call wp_get_secret()->reveal(). What it protects you from is the far more common case where a copy of the database ends up somewhere it should not: a dump in a home directory, a backup in a bucket, a staging clone handed to a contractor, a read-only replica, an SQL injection that reads wp_options, a screenshot of options.php.
The thread has not been quiet. On 4 September, after two managed hosts argued that secrets must be read-only from the WordPress side and that encryption belongs in their HSMs rather than in PHP, the author conceded a mode where the setter is a no-op and the platform supplies the plaintext through the drop-in. The framing that came out of it is the right one: a provider may be stronger than the default, never weaker.
What one options table looks like the week before
The method is the same one I used for the outbound-call audit and the application-passwords audit: read the install, count, classify, print nothing you would not want in a blog post. A short wp eval-file script pulled every row of wp_options, unserialized the ones that are arrays, and walked them looking for any field whose name matches api_key, secret, token, password, private_key, license_key or bearer. For each hit it printed the option name, the path inside the array, the autoload flag, and a shape class derived from the value (an OpenAI-style prefix, a Google key prefix, a Slack token prefix, hex, base64, and so on) plus its length. The values themselves never left the server and never hit a terminal.
It is a wider net than the one I cast over the same table in August’s API key audit, which counted only values with an unambiguous vendor prefix and found 11. Matching on the field name instead catches the tokens, secrets and licence keys that have no prefix, which is why the count is 56 here and why the two numbers do not disagree. The table itself has shrunk from 10,236 rows to 4,144 in the meantime, after the chatbot’s per-session state moved into its own table; the credentials did not move anywhere.
Out of 4,144 option rows, that returned 71 credential-shaped string fields across 50 rows. Fifteen of them are not the problem: six are noise (a rewrite rule, an endpoint slug, an error record, a token type), five are public by design (three Firebase web keys, a Maps browser key, an IndexNow key), and four are already ciphertext. That leaves 56 plaintext credentials.
| What the credential unlocks | Fields | Distinct secrets | Notes |
|---|---|---|---|
| AI model APIs (OpenAI, Anthropic, Google, xAI, DeepSeek, OpenRouter, Perplexity) | 17 | 8 | One OpenAI key is stored in 4 rows, another in 3; the Anthropic and Gemini keys are in 3 rows each |
| SEO, media, licence and connection tokens | 11 | 11 | 6 belong to plugins no longer installed |
| Email and messaging (Loops, Brevo, Slack, Telegram) | 9 | 6 | The Loops key is stored, byte-identical, in 4 rows by 3 plugins |
| Payments and store (PayPal, WooCommerce.com, a Stripe leftover) | 8 | 5 | One PayPal client secret in 3 rows, plus a PayPal bearer token stored twice in one transient |
| This site’s own agents and tooling | 5 | 5 | REST tokens, a JWT secret, an application password |
| Vector and search (Pinecone, Brave) | 3 | 3 | Two different Pinecone keys, one per add-on |
| A cloud service account | 2 | 1 | A 1,704-character RSA private key and its key ID, autoloaded |
| A plugin’s own encryption key | 1 | 1 | Stored in the same table as the values it encrypts |
| Total plaintext | 56 | 40 |
The duplication is worth a moment. Seventeen AI-vendor fields hold eight distinct keys, because each add-on that needs OpenAI asks for it again and stores its own copy. That is not carelessness on the part of any one plugin; it is what an ecosystem with no shared secret store looks like. It also means that rotating “the OpenAI key” on this site is a seven-row job, and the Secrets API’s namespaced names (plugin-slug/secret-name) will not change that on their own.
Then there is the row that made me stop: a Firebase service-account private key, the full RSA PEM block, sitting in an autoloaded option because an email-generation plugin needed to sign requests. Nothing in WordPress told the plugin author that was unusual. Nothing could have.
Four things that are true of the same 56 credentials
48 of 56 are already in a database dump. The home directory of this server holds two SQL files: a 176 MB full-site dump written on 3 March 2026 during a hosting migration, and a 6.9 MB backup of the options table taken on 7 August before a plugin update. I grepped each live credential, full value, against both files on the server. 48 of the 56 are in the March dump; 54 are in the August one. The March file is 193 days old. Every one of those 48 keys has been sitting in plaintext on disk, outside the database, for six months, and every one of them still works. This is the threat model in the proposal, and I did not have to go looking for it; it was in ~.
36 of 56 are autoloaded. The autoload flag on those rows is yes, on or auto, all of which wp_load_alloptions() loads on every request. On this install that blob is 3,022 rows and 848 KB, and 36 credentials are in it. There is no persistent object cache here (the W3 Total Cache drop-in is present, the object cache is off), so the blob lives and dies with the request. On a host with Redis or Memcached, every one of those 36 also has a second copy in the cache store, which is exactly why the proposal’s WP_Secret refuses to be serialized. The API mandates autoload = no; none of the plugins that wrote these rows had a reason to.
15 of 17 AI keys are still accepted by their vendor. Every AI vendor exposes a free, read-only models endpoint, so I sent each stored key a GET /v1/models (or its equivalent) from the server and recorded the status code. OpenAI, Anthropic, Google, DeepSeek and OpenRouter all returned 200 for every row. The xAI and Perplexity results (400 and 404) were about endpoint shape, not key state, and I am counting them as inconclusive rather than dead. Cost of the check: nothing. What it tells you: this is not a table of stale strings. These are working credentials, and the only thing between them and a bill is who has read access to the row.
11 of 56 belong to plugins that are no longer installed. I mapped every option name back to the plugin directory that references it. Eleven credential fields have no owner on disk: an old Yoast Semrush token pair, two licence or API keys for tools since removed, a hosting provider’s data token, a Stripe connection secret from a retired payments plugin, a handshake token, and four rows holding the same legacy-format OpenAI key, written by four small plugins that were tried and removed before March. That key returned 200 today. The plugins are gone; the key stayed, and it still works. WordPress has no uninstall hook that fires by default, and most small plugins do not ship an uninstall.php that cleans options, so this is the normal end state of trying things.
Who already encrypts, and what it shows
Ten of the 48 active plugins contain encryption code somewhere. On this install, two of them have produced a ciphertext row, and one of the two is ours.
| Plugin | What it encrypts | Key source | What it does not encrypt |
|---|---|---|---|
| Rank Math SEO 1.0.277 | Google OAuth access and refresh tokens; its own registration key (3 fields, AES via openssl_encrypt) | LOGGED_IN_KEY + LOGGED_IN_SALT | Its IndexNow key, which is public by design anyway |
| MxChat Documentation Bot 1.5.0 | Its own OpenAI key, AES-256-CBC via openssl_encrypt | wp_salt( 'auth' ), IV from wp_salt( 'secure_auth' ) | Nothing else; it holds one credential. The core MxChat plugin, below, does not do this. |
| WP Mail SMTP 4.9.0 | The SMTP password (smtp.pass), via its Crypto helper | Own generated key | Every API-key mailer: Brevo, Mailgun, SendGrid, Postmark and the rest go through sanitize_text_field() and are stored as typed. The Brevo key on this site is plaintext. |
| WPForms Lite 2.0.1 | Form entry fields, with a libsodium secretbox key | Generated with sodium_crypto_secretbox_keygen()… and stored base64-encoded in wpforms_crypto_secret_key, autoloaded, in the same table | Anything an attacker with the dump wants, since the key is in the dump |
| WordPress 7.1 core, Connectors | Nothing. AI provider keys are saved by register_setting() with sanitize_text_field into connectors_ai_{provider}_api_key | — | Everything, unless you define OPENAI_API_KEY, ANTHROPIC_API_KEY or GOOGLE_API_KEY as a constant or environment variable, which core checks first |
Rank Math and our documentation add-on are the interesting ones, because their key derivation is exactly the proposal’s fallback: encrypt with a key derived from the auth salts in wp-config.php. It works, and it is why those four rows are ciphertext today. It also shows the fallback’s limit. A dump plus a copy of wp-config.php decrypts everything, and the two files are usually in the same backup. That is why the proposal prefers a dedicated constant when the host can provide one, and why the drop-in exists for hosts that keep the master key outside PHP entirely.
Core deserves its own line. WordPress 7.1’s Settings → Connectors screen stores AI provider keys as ordinary options. It masks them in the REST settings response with a rest_post_dispatch filter, validates a new key against the provider before saving it, and checks an environment variable and then a constant before it ever reads the database, all of which is good practice. But if you typed the key into the screen, it is in wp_options in plaintext, on the same footing as the 56 above. The proposal cites a Trac ticket for a security audit of that very screen, and the Secrets API is the obvious answer to what the audit found. That is the most concrete reason to expect this to land: core now stores AI keys itself, and core needs somewhere better to put them.
Including ours
MxChat 3.2.20 keeps its credentials in one serialized row, mxchat_options, and twelve of the 56 are in it: the OpenAI, Anthropic, Gemini, xAI, DeepSeek and OpenRouter keys, the Brave search key, an email API key, a Slack bot token, a live-agent secret, a Telegram bot token and its webhook secret. They are plaintext. The row is autoload = off, which is the one thing it gets right, and it is read at 81 places in the codebase, which is the number that makes the migration real work rather than a search-and-replace. The only credential the plugin will take from a constant is its own REST token (MXCHAT_API_TOKEN); there is no way to keep a provider key out of the database today. One of our own add-ons encrypts its key with the salts; the core plugin, which holds twelve, does not, and that inconsistency is the same one the table above shows across the ecosystem.
Here is what I have filed to fix, in the order it should ship. First, honour constants for every provider key now, using the names core has already standardised (OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY) plus MXCHAT_-prefixed ones for the rest, checked before the option, exactly as core’s Connectors do. That takes the database out of the picture for anyone who wants it out today and costs nothing on 7.1. Second, when the feature plugin’s API surface is stable, read through a small adapter that tries wp_get_secret() when the function exists and falls back to the option when it does not, and offer a one-click wp_import_option_as_secret() migration on the settings screen with the rotation warning the proposal insists on. Third, one copy of each key, not seven. I will update this post when it ships; the plugin’s documentation will carry the constant names.
What to do this week, before any of this ships
None of the 56 gets safer by waiting for 7.2. In order of return on ten minutes:
1. Inventory. This prints names, flags and lengths and never a value:
wp db query "SELECT option_name, autoload, LENGTH(option_value) AS len
FROM $(wp db prefix)options
WHERE option_name REGEXP 'api_key|apikey|secret|token|passw|license'
ORDER BY option_name"
That catches top-level names only. Keys inside serialized settings arrays (most plugins, including ours) need a PHP walk; the pattern is maybe_unserialize() each value and recurse, matching on the field name, printing strlen() and a prefix class rather than the string. Twenty lines in a wp eval-file script.
2. Delete orphans, then rotate them at the vendor. wp option delete <name> for every row whose plugin is gone, and then, because deleting the row does nothing to the copies in your backups, revoke the key on the vendor’s side. On this site that is 11 rows and one still-live OpenAI key.
3. Find your dumps. find ~ -name '*.sql' -o -name '*.sql.gz' outside the web root, then the backup plugin’s directory inside it. Every key in a dump older than your last rotation has been exposed for that long; rotate those first. The proposal’s own position is that importing a key into the Secrets API does not make it safe, rotation does, and that holds today.
4. Move what you can to constants. Core’s AI keys: OPENAI_API_KEY and siblings in wp-config.php. WP Mail SMTP: WPMS_ON plus WPMS_SENDINBLUE_API_KEY or the equivalent for your mailer. Check your other plugins’ documentation for a constant; if one exists, the database copy can be blanked. Keep wp-config.php out of the same backup as the dump if you possibly can, which is also the argument for the dedicated WP_SECRETS_KEY constant later.
5. Turn autoload off on credential rows. wp option set-autoload <name> off. It keeps the key out of the alloptions blob and therefore out of any persistent object cache, which is the same property the API enforces. It also has a small performance upside on a table like this one.
6. Scope keys at the vendor. OpenAI project keys with a monthly budget; Google API keys restricted to the one API they are for; one key per site, so a leak from a staging clone cannot bill production. A leaked key with a $20 cap is an incident; a leaked unrestricted key is an invoice.
What the API will and will not fix
If it lands, it fixes the row-level problem for every plugin that adopts it: a dump of this table would carry 56 ciphertext blobs and one master key that is not in the table. It fixes the object-cache leak by construction. It gives WP-CLI a way to set a key without the key appearing in ~/.bash_history. And it gives core’s own Connectors screen a place to put the keys it is currently writing in the clear.
It does not fix the 48. A key that is already in a six-month-old file is compromised to the extent that file is, encrypted future or not, and the proposal is honest about that: imported secrets are flagged for rotation, not blessed. It does not fix the seven copies of one key, because adoption is per plugin and each plugin will import its own. It does not fix anything for a plugin that never calls the new functions, and the 7.2 release will contain no UI, so a site owner cannot migrate a plugin’s key on the plugin’s behalf until 7.3 at the earliest. And it does nothing against code running inside WordPress, which is the class of attack that every plugin zero-day actually is.
The right way to read the proposal, then, is as infrastructure for the next six years of plugins rather than a fix for the last six months of yours. The fix for the last six months is the list above, and it takes an afternoon. I did the first and third steps on this site today, the inventory and the dump search. The deletions and the rotation are on the owner’s list rather than mine, because revoking keys is not something an automated process should do unasked.
FAQ
Is the Secrets API definitely in WordPress 7.2?
No. As of 12 September it is a proposal with a pre-alpha feature plugin. The stated plan is a Trac patch in late September and a commit-or-defer decision before Beta 1 on 20–22 October. If it defers, the next opportunity is 7.3. The admin UI is already scheduled for 7.3 regardless.
Will my existing plugins’ API keys be encrypted automatically when it ships?
No. The proposal rules out an automatic sweep of the options table, because core cannot reliably tell a credential from any other string. Each plugin has to call wp_import_option_as_secret() for the options it owns, and the imported value is flagged for rotation rather than treated as safe.
Does encrypting the options table protect me if my site is hacked?
Not if the attacker is running code inside WordPress. The proposal says so directly. It protects against copies of the database being read elsewhere: dumps, backups, staging clones, replicas, SQL injection. On this site that was the live risk, and it was 48 keys wide.
Why not just put every key in wp-config.php now?
Do, where the plugin supports it; core’s Connectors and WP Mail SMTP do, and I have filed the change for MxChat. The limits are that most plugins do not read constants, that wp-config.php is usually in the same backup as the database, and that a constant cannot be rotated from the admin, which is the operational gap the 7.3 UI is meant to close.
How much did this audit cost?
Nothing. One WP-CLI script for the inventory, one grep per credential against two files on the server, and seventeen free GET requests to vendors’ model lists. The most expensive part was reading the results.