WordPress API Key Security: I Audited 10,236 Options
Every guide to WordPress API key security says roughly the same thing: your keys live in the database, so protect the database. That sentence is true and it is also misleading, because it describes where the key sits at rest and not where it spends most of its life.
I ran the audit on a production site — this one — to find out what the real answer looks like. The site runs 49 active plugins on WordPress 7.0.2, several of which talk to an LLM provider. The wp_options table holds 10,236 rows. Of those, 11 contain an unambiguous server-side secret: an OpenAI sk- key, an Anthropic sk-ant- key, a Pinecone pcsk_ key, or a live Stripe secret.
Ten of those eleven are read into PHP memory on every single request, including anonymous front-end page views by visitors who never log in. Not queried when needed. Loaded, always, into a variable that any code running in that request can reach.
That is the part the “protect your database” framing misses, and it changes what a plugin vulnerability actually costs you.
What “autoloaded” really means
WordPress keeps a fast path for settings it expects to need constantly. On boot it runs a single query that pulls every option flagged for autoload into one array, caches it as alloptions, and hands it to get_option() from memory thereafter. It is a sensible performance design and it has nothing to say about sensitivity.
On this install the numbers line up exactly:
autoload value | Rows | Loaded every request? |
|---|---|---|
auto | 2,462 | Yes |
on | 140 | Yes |
yes (legacy) | 430 | Yes |
off | 7,013 | No |
no (legacy) | 191 | No |
| Total | 10,236 | 3,032 loaded |
2,462 + 140 + 430 = 3,032, which is exactly the size of the alloptions array returned by wp_load_alloptions(). Every row marked auto on this site is in fact autoloaded — auto means “let WordPress decide”, and for options under its size threshold WordPress decides yes.
So roughly 30% of the options table is resident in memory for every request, and ten API keys are in that 30%.
The advice you’ll find is written against a vocabulary that changed
If you search for how to fix this, most of what you’ll read tells you to set autoload to 'no'. That advice is dated. WordPress 6.4 introduced wp_set_option_autoload_values(), and the storage vocabulary moved to on / off / auto. The developer documentation now states that 'yes' and 'no' are accepted only for backward compatibility and that using them is deprecated in favour of booleans.
The migration is not retroactive, which is why this site shows 621 rows still on the legacy yes/no vocabulary sitting alongside 9,615 on the modern one. Both work. But if you write a cleanup script that filters on autoload = 'no', you will silently skip 7,013 rows, and if you filter on 'yes' you will miss 2,602 autoloaded options. Match on the loaded set, not on a string you assume is in the column.
Why an LLM key is not like a leaked password
A compromised user password is bad in a way people understand. An exfiltrated OpenAI key is bad differently, and the differences all run in the wrong direction.
| Leaked user password | Leaked LLM API key | |
|---|---|---|
| Who notices first | Often the user | Usually nobody, until billing |
| Cost of abuse | Fixed | Metered — scales with the attacker’s appetite |
| Where the alert goes | Site owner | The API account owner, who may not be the site owner |
| Blast radius | One account | Every project on that key, including other sites |
| Reset friction | One form | Rotate, then find and update every consumer |
The third row is the one that bites agencies. If a developer provisioned the key, the spend alert lands in the developer’s inbox, not the client’s — and a key resold for credit abuse can run for days before the invoice makes it obvious. It is a spending instrument sitting in a settings table.
The fourth row is why key hygiene beats key hiding. If one key is shared across a staging site, a production site, and a local machine, the compromise of the weakest one is the compromise of all three.
The number that gives this teeth
None of the above matters if plugin vulnerabilities are rare. They are not. Patchstack’s State of WordPress Security in 2026 report counts 11,334 new vulnerabilities found across the WordPress ecosystem, a 42% increase on the prior year, with 91% in plugins and 9% in themes. It also found that 46% did not have a patch available at the time of disclosure.
One detail worth getting right, since it is routinely mangled: the report is titled for 2026 but the dataset is 2025. If you see “11,334 vulnerabilities in 2026” quoted anywhere, that write-up did not open the report.
The relevant consequence is not that some specific plugin is dangerous. It is arithmetic: the more plugins you run, the higher the odds that one of them, at some point, hands an attacker the ability to execute code in a request. And the moment that happens, alloptions is already populated. There is no additional step, no second exploit, no need to reach the database separately. The keys are in the room.
Auditing our own plugins, including where we come off badly
Running this on a site we own means the results include our own code, so here they are.
The single option out of eleven that is not autoloaded is mxchat_options — MxChat’s own core settings blob, which holds the OpenAI, Anthropic and Google credentials the chatbot uses. It is stored with autoload disabled, so it is fetched only when the plugin actually needs it. That is the correct behaviour and it is the reason our flagship is the exception in this dataset rather than an example in it.
Our add-ons do not all match that standard. Two auxiliary option rows belonging to add-on features — the prompt library and the Pinecone vector integration, both of which hold a provider secret — are stored as auto and are therefore loaded on every request. They should be off, for the same reason the core blob is. That is a defect on our side, it is now filed, and I would rather write it down here than quietly fix it and describe the audit as clean.
There is also a category I want to be careful about. Five further options hold Google AIza-format keys and all five are autoloaded — but that prefix is genuinely ambiguous. A Firebase web config key and a browser-restricted Maps key are designed to be public and are shipped to the browser by intent; a Gemini API key with the same prefix is a server-side secret. Counting all five as exposures would have made the headline number 16 instead of 11 and would have been wrong. Prefix matching finds candidates. It does not classify them.
Run this on your own site
The audit is three commands and it is worth more than reading anyone’s summary of it, including this one. With WP-CLI:
# How many options load on every request?
wp eval 'echo count(wp_load_alloptions()), "n";'
# Which of them look like credentials?
wp eval 'foreach (wp_load_alloptions() as $k => $v) {
if (preg_match("/b(sk-[A-Za-z0-9_-]{16,}|sk-ant-|pcsk_|sk_live_)/", (string) $v)) {
echo $k, "n";
}
}'
# What is the autoload spread?
wp db query "SELECT autoload, COUNT(*) FROM $(wp db prefix)options GROUP BY autoload;"
The second command prints option names only, never values. Keep it that way — the last thing a credential audit should do is write secrets into your shell history.
If it returns nothing, you are either not running AI plugins or they store keys somewhere better. If it returns a list, that list is what one code-execution bug reaches.
What actually helps
Ranked by how much risk each removes for the effort involved.
| Fix | Effort | What it changes |
|---|---|---|
| Set spend limits at the provider | Two minutes | Caps the damage regardless of how a key leaks. Do this first. |
| One key per site, never shared | Low | Contains a compromise to one property instead of all of them. |
| Use provider-side key scoping | Low | A restricted key that can only call the endpoints you use is worth far less to an attacker. |
Move the key to wp-config.php | Medium | Takes it out of the database and out of any options-table dump. Needs plugin support for a constant. |
| Turn off autoload on credential options | Low | Removes it from the always-resident array. Real, but partial — the row is still readable. |
| Rotate on any staff change | Low | Keys outlive the people who created them. This is the most commonly skipped step. |
| Keep plugins updated | Ongoing | 46% of disclosures had no patch at the time, so this is necessary and not sufficient. |
Two honest caveats. Turning off autoload is a real improvement but it is not encryption — anyone who can run get_option() can still read the value; you have removed the free ride, not the access. And moving a key into wp-config.php only works if the plugin reads a constant. If it insists on its own settings field, that option is not available to you, which is a reasonable thing to ask a vendor about before you buy.
The pattern worth internalising is the one from the spend-limit row: assume the key will leak eventually and make that outcome cheap, rather than assuming it will not and making it catastrophic.
Frequently asked questions
Does turning off autoload break my plugin?
No. get_option() falls back to a direct query when the option is not in alloptions. The cost is one extra query on the requests that genuinely need it, which for a credential is a small number of requests.
Is storing API keys in wp_options a vulnerability?
Not on its own. WordPress has no secrets manager, so the options table is where plugins put configuration, and that is normal. It becomes a problem in combination with something else — a plugin bug, a stolen backup, an over-permissioned database user. The point of the audit is to know the size of that combination in advance.
How do I know if a key has already been abused?
Check usage on the provider’s dashboard rather than on your site. Look for requests outside your traffic pattern: volume at hours your site is quiet, models you have never configured, or regions you do not serve. Your WordPress logs will show nothing, because the calls are not coming through WordPress.
Are Firebase web API keys a security problem?
No, and treating them as one wastes your attention. They identify a project rather than authorising access, and Google publishes them in client-side config by design. Access is controlled by security rules. Do not confuse them with a Gemini API key, which shares the prefix and is a real secret.
Should I encrypt keys in the database?
It helps less than it appears to. The decryption key has to live somewhere the same PHP process can read, so code execution on the site usually reaches both. It raises the bar against a stolen database dump, which is worth something, but provider-side spend caps and scoped keys buy more safety per unit of effort.
The short version
On a real site, 30% of the options table is in memory on every request, and on this one that included ten server-side API keys. The fix is not a plugin. It is knowing which keys exist, keeping them scoped and capped at the provider, and not sharing one key across properties — so that when something eventually goes wrong, the blast radius is a line item instead of an incident.
Run the three commands. The number you get back is the number that matters, and it is probably not zero.
MxChat stores its provider credentials in a non-autoloaded option and lets you bring your own key, so your spend limits and key scoping stay under your control at the provider. You can read how the settings are structured in the MxChat documentation, or see the plans on the MxChat Pro page.
Related reading: what AI agents can actually do to a WordPress site, a first-party audit of the tool surface an authenticated agent inherits; WordPress 7.0’s AI core and what it means for chatbot plugins; and how OpenAI model retirements silently break WordPress chatbots.