Fixing WordPress REST API Authentication Errors

A practical troubleshooting guide for the WordPress REST API auth failures that keep showing up: 401s, 403s, missing Authorization headers, CORS mistakes, and expired tokens.

Tuesday, March 17, 2026
Enrique Chavez

Authentication looks fine right until every request starts failing. You post credentials, get a token back, wire up Authorization: Bearer ..., and then WordPress answers with 401 or 403 like it hates you personally.

Most of the time, the auth layer is not "randomly broken." It is one of the same few problems over and over: bad credentials, a missing Authorization header, a token that expired earlier than you thought, a proxy stripping headers, or a frontend that never sends the request you think it is sending.

I have maintained JWT Authentication for WP REST API since 2015. The plugin is running on 60,000+ WordPress sites, and the support patterns repeat. Different stack, same bug. This post is the checklist I wish more people started with.

By the end of this post, you should know how to isolate the failing step, read the actual JWT error codes instead of guessing from the HTTP status, and fix the WordPress/server/client mistakes that keep causing these auth failures.

Before you start, make sure you have:

  • access to the failing request and response body, not just the browser status code
  • a way to test outside the browser with curl, Postman, or Bruno
  • access to your server config or hosting panel if headers are involved
  • the actual auth method confirmed; if you are still choosing one, read Headless WordPress Authentication: JWT vs Application Passwords vs OAuth first

Confirm What Is Actually Failing Before You Change Anything

The first split is simple:

  • Token request fails: your credentials or login flow are wrong
  • Token request works but token validation fails: your token, headers, or server config are wrong
  • Token validation works but the real endpoint still fails: auth is probably fine, and the bug moved into permissions or app logic

Test those three steps directly before touching React, Next.js, mobile code, or WordPress hooks.

Terminal checks to isolate the failing step
# 1. Can WordPress issue a token at all?
curl -X POST https://yoursite.com/wp-json/jwt-auth/v1/token \
  -H "Content-Type: application/json" \
  -d '{"username":"your-user","password":"your-password"}'

# 2. Can WordPress validate the same token it just issued?
curl -X POST https://yoursite.com/wp-json/jwt-auth/v1/token/validate \
  -H "Authorization: Bearer YOUR_TOKEN"

# 3. Can the real protected endpoint use that token?
curl -X GET https://yoursite.com/wp-json/wp/v2/users/me \
  -H "Authorization: Bearer YOUR_TOKEN"

If step 1 fails, stop debugging headers. If step 2 fails, stop debugging your app endpoint. If steps 1 and 2 pass but step 3 fails, the problem is usually not JWT anymore.

That sounds obvious. It is also the step most people skip.

401 and 403 Do Not Tell You Enough

WordPress auth debugging gets easier once you stop treating 401 and 403 as the real signal. The useful signal is the error code and message in the response body.

These are the ones that matter most with the JWT plugin:

  • jwt_auth_failed: WordPress rejected the username/password at the /token endpoint. This is not a header problem.
  • jwt_auth_no_auth_header: WordPress never received the Authorization header. This is usually Apache, Nginx, a reverse proxy, or browser CORS.
  • jwt_auth_bad_auth_header: the header reached WordPress, but it is malformed. Usually missing Bearer, extra quotes, or a token variable that resolved to undefined.
  • jwt_auth_invalid_token: the token reached WordPress but failed to decode or verify. Common causes: expired token, wrong secret, wrong environment, stale token copied from another site, signature mismatch.
  • jwt_auth_bad_config: the site is missing JWT_AUTH_SECRET_KEY, so the plugin cannot validate anything.
  • Valid token plus failing endpoint: auth succeeded, but your endpoint permission logic still said no.
Typical JWT auth error responses
[
  {
    "code": "jwt_auth_failed",
    "message": "Invalid Credentials.",
    "data": {
      "status": 403
    }
  },
  {
    "code": "jwt_auth_no_auth_header",
    "message": "Authorization header not found.",
    "data": {
      "status": 403
    }
  },
  {
    "code": "jwt_auth_invalid_token",
    "message": "Expired token",
    "data": {
      "status": 403
    }
  }
]

One important gotcha: if /token/validate returns success but your real route still throws 401 or 403, the JWT part is already working. At that point I look at the endpoint permission_callback, user role, capability checks, or custom middleware before I touch auth again.

The Missing Authorization Header Problem

This is one of the oldest WordPress API auth bugs, and it still keeps showing up.

The plugin looks for the header in the same places WordPress exposes it: HTTP_AUTHORIZATION first, then REDIRECT_HTTP_AUTHORIZATION. If neither one exists, you get jwt_auth_no_auth_header.

That means the token may be fine. The header just never reached PHP.

On Apache, this is the classic fix:

.htaccess
RewriteEngine On
RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule ^(.*) - [E=HTTP_AUTHORIZATION:%1]

# Some Apache stacks also need this:
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1

On Nginx + PHP-FPM, this is the common equivalent:

nginx.conf (server block)
location ~ \.php$ {
    include fastcgi_params;
    fastcgi_param HTTP_AUTHORIZATION $http_authorization;
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}

If you are behind a CDN, WAF, or managed-host proxy, the header can disappear before the request ever hits WordPress. In that case:

  • Test the origin directly: if origin works and the public URL fails, the proxy is involved
  • Compare browser vs curl: if curl works and the browser does not, CORS or frontend request shaping is probably involved
  • Check for double auth layers: some managed hosts, security plugins, and reverse proxies rewrite or strip Authorization

I see people lose hours rotating secrets and reinstalling plugins when the real bug is one missing header forward.

CORS, Preflight, and Frontend Mistakes That Look Like Auth Failures

Sometimes the browser is the real liar in the room.

Your backend can be configured correctly, but if the preflight request fails, the authenticated request never runs the way you think it does. That means you end up staring at a frontend error and blaming WordPress.

If you are using JWT across origins, make sure WordPress is allowed to emit the right CORS headers:

wp-config.php
define('JWT_AUTH_CORS_ENABLE', true);

That is the plugin-level switch. It is not magic. Your server and proxy still need to allow OPTIONS, and the browser still needs the right headers.

This client wrapper helps because it forces you to inspect the real response instead of hiding it behind framework abstractions:

src/lib/wp-request.js
const API_URL = 'https://yoursite.com/wp-json';

export async function wpRequest(path, { method = 'GET', token, body } = {}) {
  const response = await fetch(`${API_URL}${path}`, {
    method,
    headers: {
      'Content-Type': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });

  const payload = await response.json().catch(() => null);

  if (!response.ok) {
    throw new Error(
      payload?.message ||
        payload?.code ||
        `Request failed with status ${response.status}`
    );
  }

  return payload;
}

The frontend mistakes I keep seeing:

  • Authorization: Bearer undefined because the token is not loaded yet when the first request fires
  • stale tokens in localStorage after switching between local, staging, and production (localStorage is still XSS bait, by the way)
  • missing Authorization in allowed headers during preflight
  • trying to debug JWT while still sending credentials: 'include' and mixing cookie assumptions into a token flow
  • testing only in Postman and assuming the browser will behave the same way

If Postman works and the browser does not, stop blaming WordPress first. The browser is adding another layer of rules.

Expired Tokens, Bad Storage, and Refresh-Flow Mistakes

A lot of jwt_auth_invalid_token reports are not mysterious. The token expired. Or the client kept using an old one after refresh. Or the app stored the refresh token correctly but never updated the access token in memory, so every request after the first refresh keeps failing.

That is especially common in SPAs and mobile clients where the auth state lives in more than one place.

If you are using JWT Auth Pro, keep the refresh logic tight: one retry, update both tokens, and stop if refresh fails.

src/lib/authenticated-request.js
const API_URL = 'https://yoursite.com/wp-json';

export async function authenticatedRequest(
  path,
  { method = 'GET', body, accessToken, refreshToken, onTokens } = {}
) {
  const makeRequest = async (token) =>
    fetch(`${API_URL}${path}`, {
      method,
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
      },
      body: body ? JSON.stringify(body) : undefined,
    });

  let response = await makeRequest(accessToken);

  if (response.status !== 403) {
    return response;
  }

  const error = await response.json().catch(() => null);

  if (error?.code !== 'jwt_auth_invalid_token' || !refreshToken) {
    throw new Error(error?.message || `Request failed with ${response.status}`);
  }

  const refreshResponse = await fetch(`${API_URL}/jwt-auth/v1/token/refresh`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      refresh_token: refreshToken,
    }),
  });

  if (!refreshResponse.ok) {
    throw new Error('Refresh token rejected. Force a new login.');
  }

  const refreshed = await refreshResponse.json();

  onTokens?.({
    accessToken: refreshed.token,
    refreshToken: refreshed.refresh_token ?? refreshToken,
  });

  return makeRequest(refreshed.token);
}

The rule here is simple:

  • Do not retry forever
  • Do not keep using the old access token after refresh
  • Do not share one token store between environments
  • Do not assume every 403 means "refresh now"

If the token fails immediately after issuance, compare these next:

  • secret key: does the environment use the same JWT_AUTH_SECRET_KEY across all app servers?
  • domain / issuer: was the token issued by the same site you are validating against?
  • clock drift: are your servers out of sync enough to make nbf or exp fail?

That last one is rare, but when it happens it feels insane until you look at the server time.

When the Token Is Fine but the Endpoint Still Fails

This is where people keep blaming auth for bugs that live somewhere else.

If /wp-json/jwt-auth/v1/token/validate returns jwt_auth_valid_token, WordPress already accepted the token. If your custom endpoint still rejects the request, check the route permissions next:

wp-content/plugins/my-plugin/includes/rest-routes.php
register_rest_route('my-app/v1', '/reports', [
    'methods'  => 'GET',
    'callback' => 'my_app_get_reports',
    'permission_callback' => function () {
        return current_user_can('edit_posts');
    },
]);

That endpoint works for editors and admins. It fails for subscribers even with a valid JWT.

This is not a token bug. It is your permission model doing exactly what you told it to do.

The practical test is:

  • token validate passes: auth layer is good
  • custom route fails: inspect capabilities, role mapping, or endpoint-specific auth checks

Treat those as separate systems. They are.

A Practical Debugging Checklist for Production Teams

When I need to debug this quickly, this is the order I use:

  • Reproduce outside the browser first: curl removes frontend noise fast
  • Issue a fresh token: do not reuse the one that has been sitting in storage all day
  • Validate the fresh token immediately: if /token/validate fails, stay there until it passes
  • Inspect the exact error code and message: do not debug from status code alone
  • Check whether the header reached PHP: especially on Apache, Nginx, proxies, and managed hosts
  • Compare origin vs public URL: if only one path works, the proxy layer is part of the bug
  • Check JWT_AUTH_SECRET_KEY and environment parity: one wrong secret in a cluster will make auth look random
  • Verify custom route permissions separately: valid token does not guarantee valid capability
  • Reset stale client auth state: clear old access and refresh tokens before testing again

That order saves time because it narrows the blast radius fast. Otherwise you end up changing client code, server config, and WordPress hooks in the same hour, and now you have three moving bugs instead of one.

If your team is already dealing with refresh flows, token rotation, revocation, and production debugging, that is exactly the point where the free plugin stops being enough for some projects. JWT Auth Pro features cover the refresh and management side so you are not bolting that on yourself later.

If you want the broader architecture context behind this, read Headless WordPress Authentication: JWT vs Application Passwords vs OAuth. If you want examples of where JWT actually fits, 5 Use Cases for JWT Authentication in WordPress covers that side.

The short version: do not debug auth by vibes. Prove which step is failing, read the real error, and fix that layer first. WordPress auth bugs are annoying, but they are usually not subtle.