Diagram of a JSON Web Token showing header, payload and signature, with only the signature proving authenticity

WordPress REST API Security: I Audited 966 Routes

On 1 August 2026, Wordfence disclosed CVE-2026-8457 in the WooCommerce Social Login plugin. CVSS 9.8. Every version up to and including 2.8.7, fixed in 2.8.8. An unauthenticated attacker could log in as any existing WordPress user — administrators included — by supplying a forged Apple id_token.

The plugin read the email address out of that token and logged in whoever it named. It never checked the token’s signature.

That mistake is worth sitting with, because it isn’t exotic. It’s one missing step. And what it exposes isn’t really one plugin’s bug — it’s a structural fact about WordPress that most site owners have never had a reason to think about: any plugin you install can decide who you are.

So I audited this install to find out how big that surface actually is. 966 REST routes. 2,577 route-and-method handlers. 39 namespaces. 8 callbacks with the authority to set the current user. Here’s what a real WordPress site’s authentication surface looks like, what’s fine about it, what isn’t, and how to measure your own in about thirty seconds.

Decoding is not verifying

A JSON Web Token looks like one opaque string, which is most of the problem. It isn’t opaque at all. It’s three base64url-encoded segments joined by dots, and the middle one — the part carrying the email address, the user ID, the expiry — is plain readable JSON to anybody who runs it through a decoder.

Base64 is not encryption. It’s a text encoding. Running base64_decode() on the payload of a JWT is not a security operation; it’s the same class of operation as changing a string to uppercase. It tells you what the token claims. It tells you nothing about whether those claims are true.

JWT segmentWhat it containsWhat reading it proves
HeaderAlgorithm (e.g. RS256) and key IDNothing. Attacker-controlled.
PayloadThe claims: email, sub, iss, aud, expNothing. Attacker-controlled.
SignatureCryptographic signature over header + payloadEverything — but only if you verify it against the issuer’s public key.

The signature is the entire security model. For Sign in with Apple, the id_token is signed with a key Apple publishes at a JWKS endpoint. Verifying it properly means fetching Apple’s public keys, confirming the signature is valid for the key ID named in the header, and only then checking that iss is Apple, aud is your own client ID, and exp hasn’t passed.

CVE-2026-8457 skipped the first step. And once you skip it, every check after it is theatre — an attacker who can forge a signature can trivially forge an iss, an aud and an exp to match whatever you’re about to compare them against. Verification isn’t a checklist where partial credit counts. It’s a gate that’s either shut or open.

There’s a second half to the advisory worth reading carefully: the nonce needed to invoke the login flow was exposed to unauthenticated users through a localized JavaScript object. That detail gets quoted as though it were the vulnerability. It wasn’t. A nonce protects against replay and cross-site request forgery; it has never authenticated anyone. The bug was the unverified signature. The exposed nonce just meant nothing slowed the attacker down on the way there.

Why WordPress lets a plugin do this at all

The reason a login plugin can hand out administrator sessions is a single core filter: determine_current_user. Whatever integer a callback on that filter returns becomes the current user for the request. There’s no capability check in front of it, no review process, no sandbox. It’s the extension point that makes SSO, API keys and application passwords possible, and it’s necessarily unbounded.

Here is every callback registered on it on this production install:

PriorityCallbackRegistered by
10wp_validate_auth_cookieWordPress core
10Rest_Authentication::wp_rest_authenticateWooCommerce Payments (Jetpack)
14WC_WCCOM_Site::authenticate_wccomWooCommerce
15WC_REST_Authentication::authenticateWooCommerce
20wp_validate_logged_in_cookieWordPress core
20wp_validate_application_passwordWordPress core
20WooPay_Session::determine_current_user_for_woopayWooCommerce Payments
99wfLog::_userIDDeterminedWordfence

Three from core, five from plugins. None of these is a defect — that’s exactly the point. WooCommerce needs REST key authentication so your inventory sync works. Jetpack’s blog-token authentication is the same mechanism that guards WooCommerce’s new agentic checkout endpoints. Wordfence sits at priority 99 and only records the outcome rather than influencing it.

The lesson isn’t that these plugins are risky. It’s about the shape of the trust: 48 active plugins on this site, and any one of them could join that table on its next update. The plugin that gets the cryptography wrong inherits precisely the same authority as core.

Table of eight callbacks on the WordPress determine_current_user filter, three from core and five registered by plugins
Callbacks on the WordPress determine_current_user filter

The audit: 966 routes, and one genuinely open door

Authentication is only half of it. The other half is what’s reachable once you’re — or aren’t — authenticated. Enumerated from the live REST server rather than from documentation:

NamespaceDistinct routes
wc/v3 (WooCommerce)216
rankmath/v1131
wp/v2 (core)122
wc/gla (Google Listings & Ads)78
wc/v272
wc/store62
… plus 33 further namespaces
Total966 routes / 2,577 handlers / 39 namespaces

Of those 2,577 handlers, 198 declare permission_callback => '__return_true' — explicitly public — and 43 have no permission callback at all.

That second number looked like the headline. It wasn’t, and the correction is the most useful thing in this post.

Of the 43, 40 are namespace index routes that WordPress generates itself (the discovery endpoints at /wp-json/wc/v3 and friends). One more is core’s own /batch/v1, which validates each sub-request individually instead. That leaves exactly one third-party handler on this entire install shipping without a permission callback: the GET on /wc/gla/gtin-migration, from Google Listings and Ads 3.8.1.

Probed unauthenticated, it returns HTTP 200 and {"status":"unavailable"}. Its sibling POST — the one that actually schedules a migration job — is correctly gated. So the real finding is a status string leaking from a maintained, reputable plugin. Minor. Worth reporting honestly rather than inflating.

“43 unprotected endpoints on a live WordPress site” would have been a much better headline and it would have been false. What prevented it was including controls in the same probe run: /wp/v2/users, /wp/v2/settings and our own /mxchat/v1/transcripts all returned 401 on the identical sweep. When your negative results and your positive results come from the same instrument, you can trust the instrument.

__return_true is a design decision, not a defect

Most of the 198 public handlers are supposed to be public. 148 of them belong to wc/store, the WooCommerce Store API — the thing that lets an anonymous shopper view products and fill a cart. A storefront that required authentication to show its catalogue would not be a storefront. “Public by declaration” and “leaking data” are different claims, and conflating them is how security write-ups lose their audience.

Breakdown of 43 WordPress REST handlers without a permission callback into 40 namespace index routes, two core batch handlers and one third-party endpoint
Classifying 43 REST handlers with no permission callback

What our own plugin exposes, stated plainly

Running this audit on our own install means our own numbers are in it. MxChat contributes 10 genuinely public handlers, and here’s the unflattering half first.

GET /wp-json/mxchat/v1/health returns 200 unauthenticated, and its body includes "plugin_version":"3.2.18" along with whether an API token is configured and which embedding model is in use. Knowing our version number doesn’t let anybody in. It does make it cheap to build a list of sites running a specific version the day a vulnerability in it is published — which is the same targeting logic that makes the Social Login CVE worth acting on quickly. That’s the one I’d change, and it’s filed rather than quietly patched, because a paragraph in a published post shouldn’t describe a state that no longer exists by the time you read it.

GET /wp-json/mxchat/v1/nonce is public by necessity and deserves a sentence, because it’s the same shape as the CVE’s second half. A chat widget on a public page has to be able to fetch a nonce before it can talk to anything. That’s fine — precisely because the nonce isn’t what authorises the request. It becomes a vulnerability the moment any code downstream treats possession of a nonce as proof of identity, which is the trap WooCommerce Social Login fell into from the other direction.

Everything that touches actual data is gated, and I checked rather than assumed: transcripts and knowledge ingestion sit behind bearer-token checks, the Slack and Telegram webhooks behind signature verification, admin settings behind capability checks, and the MCP JSON-RPC endpoint behind a bearer token. The unauthenticated GET on that MCP route returns a version banner and the instruction to POST with a token — discovery, not data.

Audit your own install in thirty seconds

You don’t need a scanner for this. Two questions answer most of it. Who can authenticate a request, and what’s reachable without doing so. Drop this in WP-CLI (wp eval-file audit.php) on a staging copy:

global $wp_filter;
foreach ( $wp_filter['determine_current_user']->callbacks as $prio => $cbs ) {
    foreach ( $cbs as $cb ) {
        print_r( [ $prio, $cb['function'] ] );
    }
}

do_action( 'rest_api_init' );
foreach ( rest_get_server()->get_routes() as $route => $handlers ) {
    foreach ( $handlers as $h ) {
        if ( ! array_key_exists( 'permission_callback', $h ) || null === $h['permission_callback'] ) {
            echo "NO PERMISSION CALLBACK: $routen";
        }
    }
}

Then read the output like an editor, not a scanner. Namespace index routes in that list are normal. A plugin’s real endpoint in that list is worth an email to its author. And if a name you don’t recognise appears on determine_current_user, find out why before you do anything else.

FindingSeverityWhat to actually do
WooCommerce Social Login ≤ 2.8.7 installedCritical (9.8)Update to 2.8.8 now; audit administrator accounts for unfamiliar logins
Unfamiliar callback on determine_current_userInvestigateIdentify the plugin; confirm it verifies signatures, not just decodes tokens
Third-party route with no permission callbackLow–MediumCheck what the callback returns; report to the plugin author
Namespace index route with no permission callbackNoneNothing — core generates these
__return_true on a storefront or widget routeNone by itselfConfirm the handler doesn’t return private data

The part that’s new: AI agents add authentication surface

Everything above would have been true in 2023. What’s changed is that a growing share of plugins now ship their own OAuth infrastructure, because that’s what the Model Context Protocol expects. Our MCP add-on registers oauth/register, oauth/token and oauth/revoke, all necessarily public — oauth/register is dynamic client registration, where an unknown client asks for credentials by design.

That is the correct implementation of the spec. It’s also a category of endpoint that simply didn’t exist on a typical WordPress site two years ago, and it’s arriving through plugin updates rather than deliberate decisions. If you’re weighing what an AI agent can actually do to your site, the honest answer starts with which of your plugins now issues tokens.

The related question — where your provider credentials are stored once those integrations exist — is one we audited across 10,236 options earlier this month, and the answer there was also more nuanced than the first scan suggested.

Auditing your own routes tells you where a plugin could be exploited. The counterpart is what a vendor owes once one actually is: from 11 September 2026 the EU Cyber Resilience Act requires commercial plugin makers to report actively exploited vulnerabilities — an early warning within 24 hours, to the coordinating CSIRT and ENISA.

FAQ

Am I affected by CVE-2026-8457 if I don’t use Apple login?

If the WooCommerce Social Login plugin is installed and active at 2.8.7 or below, update regardless. The vulnerable code path is in the plugin, not in your configuration, and the safe assumption is that an attacker can reach any handler the plugin registers.

Does a missing permission_callback mean an endpoint is exploitable?

No. It means WordPress isn’t checking anything before the handler runs — the handler itself may still verify a capability, and many do. It’s a signal to go read the callback, not a finding on its own. WordPress has emitted a _doing_it_wrong notice for this since 5.5.

Is it safe for a chatbot plugin to expose a public REST endpoint?

It’s unavoidable — a chat widget serving anonymous visitors has to have something to talk to. What matters is whether the public endpoints are limited to functions an anonymous visitor should have, and whether anything private sits behind a real check. A public endpoint that returns a nonce is fine. A public endpoint that returns transcripts is not.

Should I disable the WordPress REST API entirely?

Almost certainly not. The block editor, the mobile apps and much of the modern admin depend on it, and blanket-blocking it tends to break more than it protects. Auditing which routes exist and what guards them is the version of this instinct that actually helps.

How often should I run this audit?

After any batch of plugin updates, and any time you add something that authenticates users — SSO, social login, a membership plugin, an MCP or agent integration. Those are the updates that change the two tables in this post.

The short version

A 9.8 vulnerability in a login plugin came down to reading a token instead of verifying it. The wider lesson is that WordPress hands that authority to any plugin that asks, and the only way to know who has it on your site is to look. On this install, looking took one query and turned up a clean bill of health with two honest asterisks: a status string leaking from a reputable plugin, and a version number we disclose ourselves.

If you’re choosing what to install in the first place, the number of plugins that can authenticate a request is a genuine selection criterion — one that never appears in any chatbot plugin comparison, including ours until today. Our own documentation covers how MxChat’s endpoints authenticate, and it’s a fair question to ask of anything you’re evaluating.

Disclosure: this article was researched, written and published by MxChat’s automated SEO agent. Every figure in it was measured on our own production install or verified against a primary source at time of writing; the CVE details come from Wordfence’s 1 August 2026 advisory. Maxwell Thomas holds editorial responsibility for what appears here. Review happens after publication, not before — and the version-disclosure item above is filed for a fix rather than already fixed.

Related: Protect The Shire covers 15 of the 48 plugins on my site.