14+ years building on WordPress / Replies in under 5 hours
Headless WordPress 9 min read · Updated July 2026

JWT Authentication for Headless WordPress: Complete Implementation Guide

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
Securing Headless WordPress: Implementing JWT Authentication
TL;DR

Standard WordPress cookie authentication breaks across domains in headless setups — the browser's same-origin policy blocks cookies between your frontend and backend domains. JWT authentication fixes this: the frontend POSTs credentials to /wp-json/jwt-auth/v1/token, receives a signed token, and sends it as an Authorization: Bearer header on every subsequent request. Store tokens in httpOnly cookies set by your Next.js server layer, not in localStorage — localStorage is readable by any JavaScript on the page including third-party scripts. Set short expiration times (1 hour) and implement refresh token rotation for production. Every custom REST endpoint must still use permission_callback with current_user_can() — the JWT tells WordPress who the user is, not what they're allowed to do. For server-to-server requests (build-time data fetching, cron jobs), use Application Passwords instead — they're simpler to manage and can be revoked individually from wp-admin.

Standard WordPress authentication uses PHP sessions and cookies. When your frontend lives on myapp.com and your WordPress backend lives on api.mysite.com, the browser’s same-origin policy blocks those cookies from being shared across domains. Every authenticated request fails with a CORS error before it reaches WordPress.

JWT (JSON Web Token) authentication solves this by replacing the session cookie with a signed token. The frontend exchanges a username and password for a token, then includes that token in the Authorization header on every subsequent request. The token works across domains, is stateless on the WordPress side, and can be verified without a database lookup.

This guide covers the full implementation: the WordPress plugin setup, the JavaScript token request, secure token storage, protecting custom REST endpoints, and when to use Application Passwords instead of JWT.

What a JWT Actually Is

A JWT is a base64-encoded string in three parts separated by dots: header.payload.signature. The header specifies the algorithm (HS256 by default). The payload contains claims — standard ones like exp (expiration timestamp) and iat (issued at), plus WordPress-specific ones like data.user.id. The signature is a hash of the header and payload, signed with your secret key.

When WordPress receives a request with a JWT in the Authorization header, it re-computes the signature using the same secret key and compares it against the signature in the token. If they match and the token hasn’t expired, the request is authenticated — no database session lookup required. If the secret key changes, every existing token becomes invalid immediately, which is your emergency revocation mechanism.

Step 1: Install and Configure the JWT Plugin

The JWT Authentication for WP-REST API plugin is the standard implementation. Install it from the WordPress plugin repository, then add two constants to wp-config.php:

// wp-config.php — add above "That's all, stop editing!"

// A random 64-character string — generate with: openssl rand -hex 32
define('JWT_AUTH_SECRET_KEY', 'your-64-char-random-secret-here');

// Adds CORS headers to JWT plugin responses
define('JWT_AUTH_CORS_ENABLE', true);

Generate the secret key with openssl rand -hex 32 — this produces a 64-character cryptographically random string. Store it as a server environment variable and reference it with getenv() rather than hardcoding it in wp-config.php directly, so it never appears in version control.

Step 2: Allow the Authorization Header Through Apache

Apache strips the Authorization header before PHP sees it unless you explicitly pass it through. Add this to your .htaccess file in the WordPress root:

# .htaccess — Apache strips Authorization headers by default
# Add this inside your WordPress <Directory> block or at the root

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

On nginx, the header passes through by default — no configuration needed. On LiteSpeed hosting, check whether the header stripping is happening at the web server level; some managed hosts strip it in their security rules.

Step 3: Request a Token from the Frontend

The plugin exposes a /wp-json/jwt-auth/v1/token endpoint that accepts a POST request with credentials and returns a signed token:

// Get a JWT token from WordPress (JavaScript / Next.js)
async function loginToWordPress(username, password) {
  const res = await fetch('https://api.yourdomain.com/wp-json/jwt-auth/v1/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, password }),
  });

  if (!res.ok) throw new Error('Login failed');

  const { token, user_email, user_nicename } = await res.json();
  // token is a signed JWT string: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
  return { token, user_email, user_nicename };
}

The response includes the token string, the user’s display name and email, and a token_type: "JWT" field. The token itself is opaque to the frontend — it doesn’t need to decode it; it just stores it and sends it back on future requests.

Step 4: Use the Token on Authenticated Requests

Include the token in the Authorization: Bearer header on any request to a protected endpoint:

// Use the JWT token in a Next.js API call to a protected endpoint
async function fetchPrivateData(token) {
  const res = await fetch('https://api.yourdomain.com/wp-json/wp/v2/users/me', {
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
  });

  if (res.status === 403) throw new Error('Token expired or invalid');
  return res.json();
}

When the JWT plugin is active, WordPress automatically reads the Authorization header on every REST request, validates the token, and sets the current user context before your endpoint callback runs. Your endpoint code calls current_user_can() as usual — the JWT auth is transparent to it.

Secure Token Storage: Why Not localStorage

The previous version of this post mentioned localStorage as an option for storing JWTs. It’s worth being direct: storing access tokens in localStorage is a security anti-pattern. Any JavaScript on the page — including third-party scripts from analytics, advertising, or CDN-hosted libraries — can read localStorage. A single XSS vulnerability anywhere on the page gives an attacker the token.

The correct approach depends on what you’re building:

  • For a Next.js application handling user sessions: store the JWT in an httpOnly cookie set by your Next.js server (not by client-side JavaScript). An httpOnly cookie is inaccessible to JavaScript entirely — only the browser includes it in HTTP requests to your server. Your Next.js API routes then forward it to WordPress as needed.
  • For a stateless SPA without a server layer: store the token in memory (a JavaScript variable or React state) — it disappears on page refresh, requiring re-login, but is never exposed to XSS. This is the right trade-off for high-security applications where persistent sessions are acceptable to sacrifice.
  • Never: sessionStorage (same XSS risk as localStorage, just tab-scoped), or any DOM-accessible storage.

The WordPress security guide covers XSS and injection threats in more detail — relevant context for any headless implementation that processes user-generated content before rendering it.

Token Expiration and Refresh Tokens

The JWT plugin issues tokens that expire after 7 days by default. Reduce this for higher-security applications using the jwt_auth_expire filter:

Add to functions.php:

// functions.php — shorten JWT expiration to 1 hour
add_filter('jwt_auth_expire', function ($expire, $issued_at) {
    return $issued_at + (60 * 60); // 1 hour in seconds
}, 10, 2);

Shorter-lived access tokens reduce the window of exposure if a token is stolen. The trade-off is that users get logged out more often. The standard pattern to balance both: short-lived access tokens (15 minutes to 1 hour) plus a long-lived refresh token (7–30 days). When the access token expires, the frontend silently exchanges the refresh token for a new access token without prompting the user to log in again.

The JWT plugin doesn’t include a refresh token endpoint out of the box. For a production implementation, either extend it with a custom REST route that issues a new token given a valid (but expiring) one, or use a more complete authentication package like WP Simple JWT Login, which includes refresh token support.

Securing Custom REST Endpoints

A valid JWT tells WordPress who the user is — it doesn’t automatically restrict what they can do. Your custom endpoints must still check capabilities explicitly using permission_callback:

// functions.php — securing a custom REST endpoint

add_action('rest_api_init', function () {
    register_rest_route('myapp/v1', '/private-data', [
        'methods'             => 'GET',
        'callback'            => 'myapp_get_private_data',
        'permission_callback' => function () {
            // current_user_can() resolves the JWT token automatically
            // when the JWT plugin is active — no manual token parsing needed
            return current_user_can('read');
        },
    ]);
});

function myapp_get_private_data($request) {
    $user = wp_get_current_user();
    return rest_ensure_response([
        'user_id'    => $user->ID,
        'user_email' => $user->user_email,
        'data'       => get_user_meta($user->ID, 'private_field', true),
    ]);
}

The permission_callback runs before the main callback. If it returns false or a WP_Error, WordPress returns a 403 before the callback executes. Returning __return_true or omitting the callback entirely makes the endpoint public — never do this for endpoints that return user data or modify content.

For granular access control — restricting certain endpoints to editors but not subscribers, or building a custom role for API-only access — the WordPress roles and capabilities guide covers how to create and assign capabilities that map cleanly onto current_user_can() checks in REST endpoints.

Application Passwords: The Simpler Alternative

For server-to-server requests — Next.js fetching WordPress content at build time, a Node.js cron job, or a CI/CD pipeline pulling post data — JWT authentication is more complexity than you need. WordPress 5.6+ includes Application Passwords natively.

Generate an Application Password in wp-admin under Users → Profile → Application Passwords. Use it with HTTP Basic Auth:

// Application Password usage (server-to-server / build-time fetching)
// Generate via WordPress admin: Users → Profile → Application Passwords

const credentials = Buffer.from('username:app-password-here').toString('base64');

const res = await fetch('https://api.yourdomain.com/wp-json/wp/v2/posts', {
  headers: {
    'Authorization': `Basic ${credentials}`,
  },
});

Application Passwords are scoped to a specific user and can be revoked individually from wp-admin without changing the site’s JWT secret. They’re ideal for machine-to-machine requests where a human session isn’t involved. The limitation: they only work over HTTPS, and they don’t support fine-grained per-request expiration the way JWTs do.

Use JWT when you need user-facing authentication (login flows, per-user data, session management). Use Application Passwords for background server processes and build-time data fetching.

CORS Configuration for the WordPress Backend

Setting JWT_AUTH_CORS_ENABLE to true adds CORS headers to JWT plugin responses, but it doesn’t configure CORS for all WordPress REST API routes. Add a filter to functions.php to allow your frontend domain:

// functions.php — allow your frontend origin on all REST routes
add_action('rest_api_init', function () {
    remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');
    add_filter('rest_pre_serve_request', function ($value) {
        $origin = get_http_origin();
        $allowed = ['https://yourapp.com'];
        if (in_array($origin, $allowed, true)) {
            header('Access-Control-Allow-Origin: ' . esc_url_raw($origin));
            header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
            header('Access-Control-Allow-Headers: Authorization, Content-Type');
            header('Access-Control-Allow-Credentials: true');
        }
        return $value;
    });
}, 15);

Replace https://yourapp.com with your actual frontend domain. Avoid using * (allow all origins) in production — it exposes your API to cross-origin requests from any domain, defeating the protection CORS provides.

JWT Security Checklist

  1. Generate the secret key with openssl rand -hex 32 — never use a short or guessable value
  2. Store the secret in a server environment variable, not hardcoded in wp-config.php
  3. Serve the WordPress backend over HTTPS — JWTs in headers are plaintext without TLS
  4. Store tokens in httpOnly cookies (server-set) or in memory — never in localStorage or sessionStorage
  5. Set a short token expiration (1 hour for sensitive apps) and implement refresh token rotation
  6. Use permission_callback on every custom REST endpoint — never trust the JWT alone
  7. Restrict CORS to your specific frontend domain, not *
  8. Validate the Authorization header passthrough is working after any server migration or .htaccess change
  9. Rotate the secret key immediately if you suspect it has been compromised — invalidates all existing tokens
  10. Use Application Passwords for build-time and server-to-server requests; reserve JWT for interactive user sessions

JWT authentication is the foundation for any headless WordPress feature that involves user identity — member dashboards, WooCommerce checkouts, personalised content, comment submission. Once in place, the rest of the headless stack — REST API or GraphQL for data fetching, webhooks for cache revalidation, and Next.js performance optimisation — builds on this authenticated layer. For the SEO implications of the full headless setup, the headless WordPress SEO guide covers what changes when your frontend is decoupled from WordPress.

Frequently asked questions

Standard WordPress authentication relies on PHP session cookies. When your frontend (e.g. myapp.com) and WordPress backend (e.g. api.mysite.com) are on different domains, browsers refuse to send or store cookies across those domains by default — this is the browser's same-origin policy enforced through CORS. Every authenticated request to a protected WordPress REST endpoint fails with a 403 before your code runs. JWT authentication bypasses this by replacing the session cookie with a signed token that travels in the Authorization header, which works across any domain.

No. localStorage is accessible to any JavaScript running on the page — including third-party scripts from analytics, advertising, or CDN-hosted libraries. A single XSS vulnerability anywhere on the page allows an attacker to read every token from localStorage and use it to impersonate the user. The secure alternatives: store the JWT in an httpOnly cookie set by your Next.js server (httpOnly cookies are invisible to JavaScript entirely), or keep it in memory (a JavaScript variable or React state) — it disappears on page refresh but is never exposed to XSS. For most Next.js + WordPress setups, the server-set httpOnly cookie is the right choice.

JWT (via the JWT Authentication for WP-REST API plugin) is designed for interactive user sessions: a human logs in, gets a time-limited token, and the frontend uses it to access protected content on their behalf. Application Passwords (built into WordPress 5.6+) are designed for machine-to-machine communication: a server process authenticates with a static username/password pair using HTTP Basic Auth. For build-time data fetching in Next.js (generateStaticParams, getStaticProps), background sync jobs, or CI/CD pipelines, Application Passwords are simpler — no token exchange, no expiration to manage, and they can be revoked individually from wp-admin without changing the site's JWT secret.

Use the permission_callback parameter when registering your endpoint with register_rest_route(). Set it to a function that calls current_user_can() with the appropriate capability. When the JWT Authentication plugin is active, WordPress automatically parses the Authorization: Bearer header on each request and sets the current user context — your permission_callback doesn't need to handle token parsing manually. If permission_callback returns false, WordPress returns a 403 before your main callback runs. Never omit permission_callback or set it to __return_true for endpoints that return user data or modify content.

Implement a refresh token pattern: use short-lived access tokens (15 minutes to 1 hour) for API requests, and a long-lived refresh token (7–30 days) stored separately in a secure httpOnly cookie. When an API request returns a 403 due to an expired access token, the frontend silently sends the refresh token to a dedicated refresh endpoint, receives a new access token, and retries the original request — the user never sees a login prompt. The JWT Authentication for WP-REST API plugin doesn't include a refresh endpoint by default; you'll need to add a custom REST route or switch to WP Simple JWT Login, which includes refresh token support.

The JWT_AUTH_CORS_ENABLE constant adds CORS headers to JWT plugin responses, but it doesn't cover all WordPress REST API routes. Add a custom filter in functions.php to set Access-Control-Allow-Origin to your specific frontend domain on all REST responses. Avoid using * (allow all origins) in production — it allows any website to make authenticated requests to your API using a visitor's credentials. Also ensure the Authorization header is listed in Access-Control-Allow-Headers, and that Access-Control-Allow-Credentials is set to true if you're using cookies alongside the JWT (needed for the refresh token cookie flow).

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance — the kind of work that shows up as faster load times and fewer support tickets. No account managers, no outsourced tickets — you work directly with me, with replies typically inside 5 hours.

Work with me →