//nefariousplan

Nonce Is Not Auth

CSRF tokens mistaken for authentication. Valid nonce does not equal authenticated caller.

A nonce tells you the request is fresh. That is not the same as telling you the requester is authorized. The first is about replay. The second is about identity.

WordPress plugin code, year after year, treats these two as interchangeable. A handler calls wp_verify_nonce() at the top, returns early on failure, and then proceeds into privileged operations: file uploads, option writes, user role changes. The nonce check succeeds. The nonce was valid. The caller was allowed to fetch one via an unauthenticated endpoint. Valid nonce, unauthenticated caller, privileged action. Three facts that should not coexist, coexisting because the gate that was supposed to check the third was never written.

The pattern sits in the gap between what wp_verify_nonce() promises and what the developer assumed it promised.

Mechanism

WordPress nonces are single-use tokens bound to a user session + action name + time window. They exist to prevent CSRF: an attacker who can trick a logged-in user into submitting a forged request cannot supply a valid nonce, because the attacker does not know the current nonce value. The wp_verify_nonce() call returns true only when the caller presents a token the server previously minted for that specific action + user + time slice.

What wp_verify_nonce() does not verify is who the caller is. The check tells you the token is fresh for its scope. It does not tell you that scope is any particular identity, nor that the identity has permission for the action the handler is about to perform. WordPress provides separate functions for those: is_user_logged_in() for presence, current_user_can() for permission. Nonce + identity + permission are three independent gates. Missing any one is a different class of bug.

The pattern lands when handler code treats nonce verification as a sufficient gate. The shape is consistent: a handler registered via wp_ajax_pix_upload and wp_ajax_nopriv_pix_upload, the nopriv registration making the endpoint callable without authentication. The handler's first line is check_ajax_referer('pix_action') or wp_verify_nonce on a POST field. The referrer check passes because the attacker harvested the nonce from a peer unauthenticated endpoint (every plugin has one). Then the handler performs the privileged operation: writes a file to the plugin's upload dir, flips a config option, creates a user. current_user_can() is never called. Identity was never verified. The nonce made the developer feel safe.

Exhibits

Boundaries

Not every nonce check is this pattern. A handler that calls wp_verify_nonce() AND current_user_can('edit_posts') is doing the right thing. The nonce handles CSRF; the capability check handles authorization. The pattern requires that the capability check is missing, not that the nonce check exists.

Not every CSRF gate. The pattern is specifically about confusing CSRF defense with authentication. A system with a different CSRF token mechanism (synchronizer tokens, SameSite cookies, Origin header checks) can fall into the same trap if its authors assume "token valid" means "caller trusted." The shape transfers beyond WordPress.

Not every unauthenticated endpoint. A handler deliberately scoped to anonymous users (a public contact form, a registration page) may legitimately have no auth check. The pattern is about handlers that perform PRIVILEGED operations under nopriv registration. The anonymous-by-design case is a design choice. The privileged-under-nopriv case is the bug.

Defender playbook

Grep your plugins for wp_ajax_nopriv_. Every match is a handler registered to accept unauthenticated callers. For each one, open the handler and verify it does NOT perform privileged operations. If it writes files, updates options, changes user state, or invokes do_action on sensitive hooks, the handler is a candidate for this pattern.

In the handler, audit for current_user_can(). If the function handles privileged operations and does not call current_user_can(), the nonce check is not saving you. Add the capability check, or remove the nopriv registration, or move the endpoint behind authentication. One of the three must be true for the handler to be correct.

Separate the question "is this request fresh?" from the question "is this caller permitted?" in your own code review mental model. Nonce answers the first. Identity plus permission answers the second. A handler that passes the first but not the second is running with wrong inputs.

Treat the registry of nopriv actions as an attack surface, not a feature list. Each entry is a documented path to reach server-side code without authentication. Maintaining that list deliberately, with owner notes on why each one is safe, is cheaper than discovering the unsafe ones through advisories.

For plugins you consume rather than author: check the plugin's changelog for nonce-related CVEs. Plugins that have shipped this pattern once often ship it again, because the codebase's handler-registration style did not change. The post linked below is an exemplar of the class as it lands.

Kinship

Trust Inversion. The nonce becomes an authorizer it was not designed to be. Trust inversion at the plugin-handler layer: the mechanism the handler trusts (nonce presence) is compromised in the specific sense that it never conferred the property the handler is relying on.

Design Debt Driver. WordPress's ajax-handler architecture produces this pattern recurrently across the plugin ecosystem. The API exposes nonces, nopriv registration, and capability checks as three separate knobs. Developers miss one of the three knobs in handler after handler. Each plugin CVE closes its specific instance. The substrate persists.

Unauth Write To Execution Path. Frequent co-occurrence. A nonce-is-not-auth handler that performs file uploads commonly writes into a path the webserver executes. The two patterns compose into the classical WordPress plugin pre-auth RCE shape: nopriv endpoint, harvested nonce, privileged write, PHP execution.

A valid nonce tells you the attacker knew the nonce. It does not tell you they are the user the nonce was minted for.