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
httpOnlycookie set by your Next.js server (not by client-side JavaScript). AnhttpOnlycookie 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
- Generate the secret key with
openssl rand -hex 32— never use a short or guessable value - Store the secret in a server environment variable, not hardcoded in
wp-config.php - Serve the WordPress backend over HTTPS — JWTs in headers are plaintext without TLS
- Store tokens in
httpOnlycookies (server-set) or in memory — never in localStorage or sessionStorage - Set a short token expiration (1 hour for sensitive apps) and implement refresh token rotation
- Use
permission_callbackon every custom REST endpoint — never trust the JWT alone - Restrict CORS to your specific frontend domain, not
* - Validate the
Authorizationheader passthrough is working after any server migration or .htaccess change - Rotate the secret key immediately if you suspect it has been compromised — invalidates all existing tokens
- 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.


