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

401 or 403? What the WordPress REST API Is Telling You

The two codes mean different things and the difference is the debugging path. Real responses for nonces, cookies, app passwords and permission callbacks.

Devon Parker · @dparker

APIs and integrations

Topic
API
Section
APIs & Integrations
Published
12 OCT 2026
Reading time
7 MIN
Words
1910W

Revised 16 OCT 2026

Technical cover graphic for 401 or 403? What the WordPress REST API Is Telling You
Generated schematic — API series, REV 2.6.

An integration goes live against a staging site, and every call to a protected route comes back with the same shape of failure. The client library logs "not permitted", somebody reads that as "the application password is wrong", rotates the credential, and nothing changes. What was actually happening is that the request never arrived as an authenticated user at all.

WordPress answers an unauthorised REST request with one of two status codes, and it picks between them using a single function. Reading that function is a faster debugging session than reading any amount of client code.

// wp-includes/rest-api.php, WordPress 7.0.7
function rest_authorization_required_code() {
    return is_user_logged_in() ? 403 : 401;
}

That is the entire rule. If the request reached the permission check with a logged-in user, the failure is a 403: the server knows who you are and is refusing the action. If it did not, the failure is a 401: there is no user, so there is nothing to check a capability against. The status code is not a comment on the request. It is a statement about whether authentication happened before the permission callback ran.

Four real responses, and which half of the problem each one names

The site used for everything below is WordPress 7.0.7 on PHP 8.5.6, installed locally, with pretty permalinks enabled, one administrator and one subscriber. The route is /wp/v2/settings, which requires the manage_options capability. Each request was sent to the running site with curl, and the bodies are copied verbatim.

First, an anonymous request with no cookie and no nonce:

GET /wp-json/wp/v2/settings HTTP/1.1

HTTP/1.1 401 Unauthorized
{"code":"rest_forbidden","message":"Sorry, you are not allowed to do that.",
 "data":{"status":401}}

Now the same request carrying a valid administrator login cookie and no X-WP-Nonce header:

GET /wp-json/wp/v2/settings HTTP/1.1
Cookie: wordpress_logged_in_...=admin|...|...

HTTP/1.1 401 Unauthorized
{"code":"rest_forbidden","message":"Sorry, you are not allowed to do that.",
 "data":{"status":401}}

This is the response that sends people down the wrong path. The cookie is genuine, the session exists, and the code is still 401. Cookie authentication in core has two halves, and the identity half is worthless without the nonce half. When the nonce is missing or wrong, core discards the cookie identity for that request and serves it as an anonymous caller. The response is honest. It says there is no user.

Third, the same route with a subscriber's cookie and a nonce minted for that subscriber's own session:

GET /wp-json/wp/v2/settings HTTP/1.1
Cookie: wordpress_logged_in_...=subtest|...|...
X-WP-Nonce: 9c0e942fed

HTTP/1.1 403 Forbidden
{"code":"rest_forbidden","message":"Sorry, you are not allowed to do that.",
 "data":{"status":403}}

The caller is now identified as subtest, and subtest lacks manage_options. That is a 403, and it is a completely different conversation: no credential change will help, because the credential worked.

Fourth, the trap. Take a nonce that was generated for the administrator's session and send it with the subscriber's cookie:

GET /wp-json/wp/v2/settings HTTP/1.1
Cookie: wordpress_logged_in_...=subtest|...|...
X-WP-Nonce: 86670145b0

HTTP/1.1 403 Forbidden
{"code":"rest_cookie_invalid_nonce","message":"Cookie check failed",
 "data":{"status":403}}

Same status code as the legitimate permission failure. Different error code, different cause, different fix. If you match on the status alone, you will spend an afternoon adding capabilities to a user whose capabilities were never the problem.

One more property of this design is worth naming before the table. The rule asks whether a user is logged in, not whether a credential was supplied. A caller can present a perfectly good application password and still receive a 401, because the authentication scheme was disabled before it was ever consulted. The status code describes the state the permission check saw, and that state can be changed by configuration long before it is changed by the client.

Error codeStatusWhat it meansWhat to change
rest_forbidden401Nobody was authenticated before the check ranSend the credential, and send it correctly
rest_cookie_invalid_nonce403A cookie was present but the nonce was not valid for itMint the nonce from the same session
rest_forbidden403A user was authenticated and lacks the capabilityThe role, the capability map, or the route's requirement
rest_forbidden_context401 or 403The route allows the request but not this context valueDrop context=edit, or supply the capability it needs
rest_not_logged_in401The route is gated on being logged in and you are notAuthenticate, or stop calling a private route

A nonce is bound to one session, and that is the point

Every nonce WordPress generates is hashed from three inputs: the action name, the current user ID, and the session token for the request. The session token is the important one. Two logins by the same user produce two different tokens, so a nonce minted in one session is not valid in the other, even though the user ID is identical.

This is why the fourth response above exists, and why it is common in practice. It shows up when a request is replayed across sessions: a staging credential pasted into a production config, a load balancer sending a request to a node whose session table does not have that token, a script that harvests a nonce once and reuses it after the session has been recreated. In each case the authenticated identity is fine and the nonce is stale relative to it.

The practical rule is that a nonce is a same-origin signal for a browser, not a credential. If you are writing a machine client, stop trying to keep a nonce alive and use a scheme that does not need one.

Application passwords fail with a 401 that looks like a wrong password

Application passwords are core's answer for machine clients, and they are gated on transport. Here is the same administrator credential sent over HTTP Basic to the same route on the same site, first without the environment flag, then with it:

$ curl -s -u 'admin:8Yjk8P6sjKNpDaaK4yNRSYAu' \
    http://127.0.0.1:8963/wp-json/wp/v2/users/me

{"code":"rest_not_logged_in","message":"You are not currently logged in.",
 "data":{"status":401}}

$ curl -s -u 'admin:8Yjk8P6sjKNpDaaK4yNRSYAu' \
    http://127.0.0.1:8963/wp-json/wp/v2/users/me

{"id":1,"name":"admin","url":"http://127.0.0.1:8931", ...}

The only difference between those two runs is that the second one had define( 'WP_ENVIRONMENT_TYPE', 'local' ) in wp-config.php. The reason is one line of core:

// wp-includes/user.php, WordPress 7.0.7
function wp_is_application_passwords_supported() {
    return is_ssl() || 'local' === wp_get_environment_type();
}

Over plain HTTP on a site that does not report itself as local, application passwords are unavailable, and a correct credential produces rest_not_logged_in with a 401. The password is not wrong. The transport is. Put the site behind TLS, or accept that this scheme is off until you do.

Environment type is not a security control

Setting WP_ENVIRONMENT_TYPE to local on a production host would re-enable a scheme that sends a reusable secret on every request without TLS. Use it on a development machine, not as a workaround.

Where permission callbacks add their own codes

Core's own routes are only half the surface. Every custom route has a permission_callback, and what that callback returns decides which of the two codes the caller sees. Returning false hands the decision to rest_authorization_required_code(), so an anonymous request gets a 401 and a logged-in one gets a 403. Returning a WP_Error replaces both.

register_rest_route( 'shop/v1', '/orders/(?P<id>\d+)', array(
    'methods'             => 'GET',
    'callback'            => 'shop_get_order',
    'permission_callback' => function ( WP_REST_Request $request ) {
        if ( ! is_user_logged_in() ) {
            return new WP_Error(
                'shop_login_required',
                'Sign in to read orders.',
                array( 'status' => 401 )
            );
        }

        if ( ! current_user_can( 'edit_shop_orders', (int) $request['id'] ) ) {
            return new WP_Error(
                'shop_order_forbidden',
                'This order belongs to another account.',
                array( 'status' => 403 )
            );
        }

        return true;
    },
) );

That pattern is worth copying because it keeps the contract the rest of the API already has: 401 for a caller with no identity, 403 for a caller whose identity was understood and rejected. Clients that branch on the status code then behave predictably, and a support conversation has a single question to answer before anything else: did the request arrive as a user, yes or no?

The counter-pattern is a callback that returns false for both cases and lets core decide, which is fine for a route where the distinction does not matter and confusing for one where it does. It also hides the reason. A body of rest_forbidden tells a client nothing it can act on.

Diagnosing this in the order that costs least

Start by asking what the server thought the request was. Send it with no credentials at all. A 401 confirms that anonymous callers are refused, which is correct behaviour and tells you the authentication layer is the thing to inspect. A 403 with no credential at all means something upstream is authenticating on your behalf, and you have a proxy or a plugin to find.

Then add the credential without the nonce. If the response stays 401, the credential is not being recognised, and for a browser-based client that is the expected answer until the nonce is present. If it flips to 403, the identity landed and the remaining question is capability, which you can answer by printing the user's capabilities rather than by reading the route definition.

Then add the nonce. If a request that was a clean 403 becomes rest_cookie_invalid_nonce, the nonce came from somewhere other than the session that is sending the request, and the fix is in how it was minted, not in what the user may do.

None of this requires a debugger. It requires reading the code field rather than the status, and being willing to conclude that the credential is fine.

Not every 401 and 403 comes from WordPress

Everything above assumes the response was generated by the REST server. On a real deployment it often is not. A web application firewall in front of the site can refuse a request before PHP runs, and the code it returns is chosen by its rules rather than by is_user_logged_in(). A reverse proxy with a cached error page can serve a stale 401 from an earlier, anonymous request to a client that is sending a valid credential right now.

The tell is in the body. Core's REST errors are JSON objects with a code and a message, and a data.status that matches the status line. A WAF block page is usually HTML, often with a request ID and a vendor name, and a 403 with an HTML body is a different species of failure. Before you debug the capability map, look at what you were actually sent.

A second edge worth knowing about is the redirect. WordPress canonicalises REST URLs, and a permalinks change or a scheme mismatch can turn a request into a 301. Some clients drop the Authorization header across a redirect and some keep it, so the same client can work on one site and fail on another with an identical 401. If your logs show a redirect immediately before the failure, fix the URL before touching the credential.

What to do on Monday

  1. Log the HTTP status and the code field on every failed REST call. The status alone merges two unrelated causes.
  2. Capture both in one line at the client, so the pair cannot be separated in the log: curl -s -w '%{http_code} ' -o /tmp/body.json "$URL"; jq -r .code /tmp/body.json
  3. For any 401 on a cookie-authenticated client, check the X-WP-Nonce header before touching the credential.
  4. For any 403, print the authenticated user's capabilities before editing a role.
  5. Move machine clients off cookie plus nonce. Use application passwords over TLS, or a scheme your own plugin can revoke.
  6. Give custom routes a permission callback that distinguishes 401 from 403 explicitly, so the next person debugging an integration gets the same signal core gives.

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