Skip to content
System nominalUTC --:--:--Articles 041

Authenticating a Machine Client Against the WordPress REST API

Cookie auth and X-WP-Nonce assume a browser session. What to use for server-to-server clients, how to store the credential, and why revocation needs a row.

Devon Parker · @dparker

APIs and integrations

Topic
API
Section
APIs & Integrations
Published
05 MAR 2026
Reading time
9 MIN
Words
2281W
Technical cover graphic for Authenticating a Machine Client Against the WordPress REST API
Generated schematic — API series, REV 2.6.

A nightly sync between a store and a fulfilment provider has been running for eight months on a cookie and a nonce. Then it starts failing at random with a cookie-check error, and the provider's on-call engineer asks you to send a fresh nonce whenever it happens. That request is the tell. A nonce is bound to a logged-in browser session, and no machine client has one.

Cookie authentication exists so that the WordPress admin can talk to itself. The wordpress_logged_in_* cookie identifies a session, and the X-WP-Nonce header proves the request came from a page the site itself served rather than from another origin that borrowed the browser's cookie. Both halves assume a browser. A server has no session to refresh and no page that handed it a nonce.

Making cookies work harder is not the answer. Authenticate the client the way two servers authenticate each other: a credential sent on every request, over TLS, that maps to an identity your code controls.

When a REST request arrives with a valid login cookie, core authenticates it and then checks the X-WP-Nonce header against the wp_rest action. Without a matching nonce, the cookie's identity is discarded for that request and the call is treated as anonymous. The nonce acts as a same-origin signal rather than as a second authentication factor; its absence means the request did not come from your own pages.

Nonces also expire. Core mints them against a tick that changes every twelve hours and accepts the previous tick, so a nonce is good for somewhere between twelve and twenty-four hours. A machine client would have to log in with a real user's password, harvest a nonce, and re-harvest it on a timer forever. Every step of that is a way for the integration to break at 02:00, and none of it buys anything.

There is one more reason to leave cookies to browsers. A cookie-authenticated request is authenticated as a user, and a browser attaches that cookie to any request it is told to make. The nonce is what protects the session from a third-party page. Rebuild the same arrangement for a server, outside a browser, and you keep the fragility while losing the protection.

Three schemes that work, and the client each one suits

Application passwords over HTTP Basic. Application passwords shipped in WordPress 5.6 and are the only scheme here that core implements for you. The client sends the account name and a generated password in the Authorization: Basic header, and core authenticates it as that user. There is no code to write on the server side.

The catch is what the credential is. An application password authenticates as a real user, so its reach is everything that user can do through the REST API. Create a dedicated account with the smallest role that does the job, and never the account you write posts with. Core treats application passwords as available only over HTTPS, so a site without TLS does not offer them at all. Some server stacks also strip the Authorization header before PHP sees it, which looks exactly like a wrong password. Check that before you start debugging the credential.

Version scope

Application passwords arrived in WordPress 5.6, and the notice for a missing permission_callback in 5.5. The bearer example further down is ordinary plugin code and needs neither.

A shared secret in a header you define. The client sends Authorization: Bearer <token>, and your permission_callback reads the header, finds the token, and compares it against a stored hash. Reach for this when the client is your own service or a partner you onboarded yourself, because you control both ends. There is no user account in the path and no session to expire at an inconvenient hour. The implementation is further down.

OAuth, when a third party acts for a user. If the integration is an app that a store owner installs and grants access to their own data, delegated authorization is the honest model: the user consents, the app gets a token scoped to that consent, and the user can revoke it without changing a password. WordPress core ships no OAuth server, so this means a plugin or a service you operate, along with the work that follows: token lifetimes, refresh, scope review, and a revocation endpoint. Do not build it because it sounds like the professional choice. Build it when a person, not a server, is the one granting access.

SchemeSuitable clientMain risk
Cookie plus X-WP-NonceJavaScript on a page the site servedNot a server scheme at all; nonces expire and the identity belongs to a human's session.
Application password over BasicAn internal service, a CLI job, a partner who accepts a user accountThe credential carries the full capability set of the account it belongs to.
Shared secret bearer tokenYour own service or a partner you configure yourselfOne static secret, rotated by hand, and no per-request revocation without a lookup.
OAuth 2.0A third-party app acting on behalf of a store ownerScopes drift wider than intended, and you now run a token service.

The first row is in the table to be ruled out. If a scheme needs a browser to be present, it is the wrong scheme for a cron job.

Store a hash, scope to a capability, and log which token was used

Whatever the transport, three properties of the stored credential decide how bad a leak turns out to be.

Do not store the secret itself. Generate it from a random source, hand it to the client once, and keep only a hash. A machine token is high-entropy by construction, so a fast hash is enough and a slow one only adds latency to every request: a token built from bin2hex( random_bytes( 32 ) ) has 256 bits of randomness behind it, and brute-forcing a SHA-256 digest of that is not a realistic attack. Passwords that humans invent are a different problem and need wp_hash_password(), which is what core uses for account passwords.

Store the token as two parts: an identifier you can look up by index, and the hash of the secret. One opaque string in the database forces a lookup that walks every row, and it makes rotation impossible to reason about. With an identifier you find the row with one indexed query and compare the rest in constant time.

Scope the token to a capability rather than to a role. "This token may read orders" is a sentence you can review, test and explain to an auditor. "This token belongs to a user with the shop manager role" becomes wrong the next time somebody edits a role, and role edits happen in every WooCommerce store that grows past one employee. Keep the scope as a short list of capability strings stored with the token, and check membership in the permission callback.

Rotation follows from that. A token that has never been rotated is a token that leaked somewhere you do not know about. Rotating means: create a second token for the same client, tell the client to switch, watch the logs until the old identifier goes quiet, then delete the old row. That sequence requires knowing which token served each request, so log the identifier with every call and never the secret.

On a WordPress 6.7 site running PHP 8.3 with MySQL 8.0 and no persistent object cache, I watched a per-request insert into a token log table add roughly 0.4 ms to each REST response. At one request per second that is about 2.6 million rows a month, which is the number that eventually matters. Logging is how you notice a problem as well: a machine client calls from a stable address on a predictable schedule, so the same token appearing from a new country, or at a rate that does not match the client's pattern, deserves an alert. Never log the credential, and never accept one in a query string. Query strings end up in access logs, proxy logs and error trackers.

Authentication says who; the permission callback still has to say whether

A valid token is not an authorization decision. It identifies a client. Whether that client may call this route is a separate question, answered by the route's permission_callback. If you leave that argument out, WordPress registers the route as publicly accessible. Since WordPress 5.5, core logs a _doing_it_wrong() notice when the argument is missing, which is a notice and not a block. The endpoint answers.

That failure mode survives code review better than most, because the developer tests with a token, gets data back, and concludes authentication works. It does. So does the same request without a token, which nobody tried.

Here is the bearer implementation. It looks the token up by identifier, compares the secret with hash_equals(), and returns a WP_Error whose status comes from rest_authorization_required_code().

function myplugin_machine_permission( WP_REST_Request $request ) {
    global $wpdb;

    $header = $request->get_header( 'authorization' );

    if ( ! $header || 0 !== stripos( $header, 'bearer ' ) ) {
        return myplugin_token_error();
    }

    $parts = explode( '.', trim( substr( $header, 7 ) ), 2 );

    if ( 2 !== count( $parts ) ) {
        return myplugin_token_error();
    }

    list( $token_id, $secret ) = $parts;

    $row = $wpdb->get_row( $wpdb->prepare(
        "SELECT id, secret_hash
         FROM {$wpdb->prefix}myplugin_tokens
         WHERE token_id = %s AND revoked_at IS NULL",
        $token_id
    ) );

    if ( ! $row || ! hash_equals( $row->secret_hash, hash( 'sha256', $secret ) ) ) {
        return myplugin_token_error();
    }

    // Authenticated. The scope check happens per route, below.
    $request->set_param( '_myplugin_token_id', (int) $row->id );

    return true;
}

function myplugin_token_error() {
    return new WP_Error(
        'myplugin_invalid_token',
        'A valid bearer token is required.',
        array( 'status' => rest_authorization_required_code() )
    );
}

Three notes on that function. Every failure branch returns the same error code and message, so a caller cannot use the response to learn whether a token identifier exists. The hash_equals() call compares two SHA-256 digests, which are the same length by construction, and that is the shape the function is meant for. And rest_authorization_required_code() returns 401 when no user is authenticated and 403 when one is; a machine client with a bad token is anonymous, so it gets 401, which is the right answer.

Capability scoping goes in a wrapper, so the credential check stays in one place and each route declares what it needs:

function myplugin_require_scope( $scope ) {
    return function ( WP_REST_Request $request ) use ( $scope ) {
        $authenticated = myplugin_machine_permission( $request );

        if ( is_wp_error( $authenticated ) ) {
            return $authenticated;
        }

        $scopes = myplugin_token_scopes( (int) $request->get_param( '_myplugin_token_id' ) );

        if ( ! in_array( $scope, $scopes, true ) ) {
            return new WP_Error(
                'myplugin_insufficient_scope',
                'This token does not carry the required scope.',
                array( 'status' => 403 )
            );
        }

        return true;
    };
}

register_rest_route( 'myplugin/v2', '/orders', array(
    'methods'             => WP_REST_Server::READABLE,
    'callback'            => 'myplugin_list_orders',
    'permission_callback' => myplugin_require_scope( 'orders:read' ),
) );

The route now fails closed. A request with no header, a malformed token, an unknown identifier, a mismatched secret and a token missing the orders:read scope all end in an error response before the callback runs. Test all five, because the interesting one is the last: a token that is perfectly valid and simply not allowed to be here.

A shared secret cannot be revoked per request without a store

Every scheme built on a static secret has the same weakness. To revoke it you must change it everywhere it is used, and until you finish, it still works. A value pasted into a partner's configuration file cannot be un-issued. If that partner rotates credentials on a quarterly release cycle, your revocation window is a quarter wide.

A better secret will not help. What you need is a lookup. The credential the client presents has to point at something you control, a row in a table you can delete or mark revoked, so revocation takes effect on the next request rather than the next deploy. Application passwords already work this way: core keeps them as user meta, the admin screen deletes one, and it stops working immediately. A secret read from a constant in wp-config.php cannot do that.

The row costs you a database read per request, which is where caching starts to look attractive. If you cache the token table in the object cache, revocation now depends on that cache entry being invalidated. Either delete the entry in the same code path that revokes the token, or keep a short lifetime and accept a small window. A revoked token that stays valid because a Redis entry has no TTL is a worse position than no cache at all.

None of this defends against a client that leaks its own token by writing it into a world-readable config file or printing it in a debug log. Rotation and per-token logging give you detection and a response. They do not give you prevention, and no header will.

What to do on Monday

  1. Pick the scheme by client type. A partner you onboarded gets a shared secret or an application password; a public app acting for store owners gets OAuth.
  2. Build the credential store before the client, with columns for identifier, hashed secret, scopes, created at, last used and revoked at.
  3. Write the permission_callback first, then call the route with no credential. It must fail. If it returns data, fix that before anything else.
  4. Reduce each token to the scopes its routes actually need, and log the token identifier on every request.
  5. Put rotation on a calendar, and allow two live tokens per client during the changeover.
  6. Alert on a token appearing from a new address or a request pattern that does not match the client's normal behaviour.

Devon Parker · @dparker

APIs and integrations

Works through REST contract design, webhook reliability, idempotency keys, retry policy and the unglamorous edge cases that appear when two systems disagree about what happened.

Continue

Related in APIs & Integrations