Why authentication is different in headless
In a traditional WordPress architecture, authentication relies on PHP session cookies. The browser sends the cookie with every request to the same domain, and WordPress verifies the session on the server. This mechanism works because the frontend and backend share the same domain.
In a headless architecture, the frontend (Next.js) and the backend (WordPress) are deployed on different domains (for example www.mysite.com and admin.mysite.com). WordPress session cookies are not sent to requests targeting the frontend. An authentication mechanism that works across domains is therefore required.
Available authentication methods
Three main methods allow authenticating a user between a Next.js frontend and a WordPress backend.
Application Passwords: server-side only
The Application Passwords generated by WordPress are permanent tokens that cannot be revoked individually. They are designed for server-to-server scripts (deployment, content synchronization), not for end-user authentication. Never store an Application Password in the browser.
JWT authentication: full implementation
JWT (JSON Web Token) is the most suitable mechanism for user authentication in a headless architecture. The principle is as follows: the user submits their credentials, the server returns a signed token, and the client attaches that token to every authenticated request.
Installing the JWT plugin on WordPress
Install the JWT Authentication plugin
Install the JWT Authentication for WP-API plugin from the WordPress directory or via WP-CLI:
wp plugin install jwt-authentication-for-wp-rest-api --activate
Configure the JWT secret key
Add a secret key in the wp-config.php file. This key is used to sign the tokens. Use a random string of at least 64 characters.
// wp-config.php
define('JWT_AUTH_SECRET_KEY', 'your-random-secret-key-of-64-characters-minimum');
define('JWT_AUTH_CORS_ENABLE', true);
Configure the .htaccess file
The Apache server must forward the Authorization header to PHP. Add this rule to the .htaccess file:
# .htaccess
RewriteEngine On
RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule ^(.*) - [E=HTTP_AUTHORIZATION:%1]
Test the login endpoint
The plugin creates a /wp-json/jwt-auth/v1/token endpoint that accepts credentials and returns a JWT token.
curl -X POST https://admin.mysite.com/wp-json/jwt-auth/v1/token \
-H "Content-Type: application/json" \
-d '{"username": "user", "password": "password"}'
Expected response:
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"user_email": "user@mysite.com",
"user_nicename": "user",
"user_display_name": "John Doe"
}
Implementation on the Next.js side
Login API route
The Next.js API route serves as an intermediary between the login form and the WordPress API. It stores the token in an httpOnly cookie, which is inaccessible to client-side JavaScript.
// app/api/auth/login/route.ts
import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const { username, password } = await request.json();
const wpRes = await fetch(
`${process.env.NEXT_PUBLIC_WORDPRESS_URL}/wp-json/jwt-auth/v1/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
}
);
if (!wpRes.ok) {
return Response.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
const data = await wpRes.json();
// Store the token in a secure httpOnly cookie
cookies().set('auth_token', data.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
return Response.json({
user_email: data.user_email,
user_display_name: data.user_display_name,
});
}
Logout API route
// app/api/auth/logout/route.ts
import { cookies } from 'next/headers';
export async function POST() {
cookies().delete('auth_token');
return Response.json({ success: true });
}
Route protection middleware
The Next.js middleware intercepts requests before pages are rendered. It checks for the authentication cookie and redirects to the login page if necessary.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
const protectedRoutes = ['/account', '/favorites', '/cart'];
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth_token')?.value;
const isProtected = protectedRoutes.some((route) =>
request.nextUrl.pathname.startsWith(route)
);
if (isProtected && !token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ['/account/:path*', '/favorites/:path*', '/cart/:path*'],
};
Token storage: httpOnly cookie or localStorage?
Fundamental security rule
Never store a JWT token in localStorage in production. A malicious script injected into the page (XSS attack) can read localStorage and exfiltrate the token. httpOnly cookies are inaccessible to client-side JavaScript, eliminating this attack vector. The trade-off is that you must configure CSRF protection for requests that modify data.
CORS configuration
The WordPress backend must allow requests from the frontend domain. This is configured in a plugin or in functions.php:
// functions.php — CORS configuration for headless
function headless_cors_headers() {
$allowed_origins = [
'https://www.mysite.com',
'https://staging.mysite.com',
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowed_origins)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Access-Control-Allow-Credentials: true');
}
}
add_action('rest_api_init', function () {
remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');
add_filter('rest_pre_serve_request', function ($value) {
headless_cors_headers();
return $value;
});
});
Role-based content access
In WordPress headless, some content can be restricted to logged-in users or specific roles. The check is performed on the server inside Next.js Server Components:
// app/account/page.tsx
import { cookies } from 'next/headers';
async function getCurrentUser(token: string) {
const res = await fetch(
`${process.env.NEXT_PUBLIC_WORDPRESS_URL}/wp-json/wp/v2/users/me`,
{
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}
);
if (!res.ok) return null;
return res.json();
}
export default async function AccountPage() {
const token = cookies().get('auth_token')?.value;
if (!token) return <p>Access denied</p>;
const user = await getCurrentUser(token);
if (!user) return <p>Session expired</p>;
return (
<main>
<h1>Hello {user.name}</h1>
<p>Role: {user.roles[0]}</p>
</main>
);
}
Security considerations
Security checklist for headless authentication
-
HTTPS is mandatory: all communications between the frontend, the backend and the browser must use HTTPS. Tokens transmitted over HTTP can be intercepted.
-
httpOnly + Secure + SameSite cookie: the JWT token must be stored in a cookie with the
httpOnly,secure(HTTPS only) andsameSite: laxorstrictattributes. -
Token expiration: configure a short lifetime for the access token (15 minutes to 1 hour) and use a refresh token with a longer duration (7 days).
-
Strong secret key: the
JWT_AUTH_SECRET_KEYmust be a random string of at least 64 characters, different for each environment (production, staging, development). -
Server-side validation: never trust token data on the client. Every permission check must occur on the server (Next.js middleware or WordPress API).
-
Restrictive CORS: only allow your frontend domains in the CORS configuration. Never use
Access-Control-Allow-Origin: *together with authentication cookies.
Key takeaways
JWT
Recommended method
JWT is the standard mechanism for user authentication in a headless architecture
httpOnly
Token storage
The httpOnly cookie protects the token against XSS attacks — never use localStorage in production
HTTPS
Mandatory transport
All authenticated communications must travel over HTTPS to prevent token interception