Cookie-based authentication is unsuitable for headless frontends and mobile apps that consume the WordPress REST API from a different origin. JSON Web Tokens (JWT) provide a stateless alternative: the client exchanges credentials for a signed token, then sends it as a Bearer header on every subsequent request. The implementation below uses only core WordPress hooks — no third-party plugin required.
Problem: A headless WordPress or WooCommerce installation needs stateless API authentication for mobile apps or JavaScript clients — session cookies and nonces do not work across origins or in non-browser environments.
Solution: Implement JWT authentication: on login, generate a signed JWT with a secret key using a library like firebase/php-jwt, return it to the client, and validate it on every subsequent request in a custom determine_current_user filter. Store the JWT secret in wp-config.php as an environment-based constant, never in the database.
The code below adds a /wp-json/auth/v1/token endpoint that issues HMAC-SHA256 signed JWTs, a custom REST authentication handler that validates the token, and a helper to refresh expiring tokens.
'HS256', 'typ' => 'JWT' ] ) );
$body = jwt_base64url_encode( json_encode( $payload ) );
$signature = jwt_base64url_encode(
hash_hmac( 'sha256', "$header.$body", JWT_SECRET_KEY, true )
);
return "$header.$body.$signature";
}
function jwt_verify( string $token ): ?array {
$parts = explode( '.', $token );
if ( count( $parts ) !== 3 ) {
return null;
}
[ $header, $body, $sig ] = $parts;
$expected = jwt_base64url_encode(
hash_hmac( 'sha256', "$header.$body", JWT_SECRET_KEY, true )
);
if ( ! hash_equals( $expected, $sig ) ) {
return null; // signature mismatch
}
$payload = json_decode( base64_decode( strtr( $body, '-_', '+/' ) ), true );
if ( ! $payload || ( $payload['exp'] ?? 0 ) < time() ) {
return null; // expired
}
return $payload;
}
// ── Token endpoint: POST /wp-json/auth/v1/token ───────────────────────────
add_action( 'rest_api_init', function () {
register_rest_route( 'auth/v1', '/token', [
'methods' => 'POST',
'callback' => function ( WP_REST_Request $req ) {
$username = sanitize_user( $req->get_param( 'username' ) ?? '' );
$password = $req->get_param( 'password' ) ?? '';
$user = wp_authenticate( $username, $password );
if ( is_wp_error( $user ) ) {
return new WP_Error( 'invalid_credentials', 'Invalid username or password.', [ 'status' => 401 ] );
}
$payload = [
'iss' => get_site_url(),
'sub' => $user->ID,
'iat' => time(),
'exp' => time() + 3600, // 1 hour
'data' => [ 'roles' => $user->roles ],
];
return [ 'token' => jwt_build( $payload ), 'expires_in' => 3600 ];
},
'permission_callback' => '__return_true',
] );
} );
// ── REST authentication filter: validate Bearer token ────────────────────
add_filter( 'determine_current_user', function ( $user_id ) {
$auth = $_SERVER['HTTP_AUTHORIZATION']
?? ( function_exists( 'apache_request_headers' )
? ( apache_request_headers()['Authorization'] ?? '' )
: '' );
if ( ! str_starts_with( $auth, 'Bearer ' ) ) {
return $user_id; // not a JWT request — fall through to cookie auth
}
$token = substr( $auth, 7 );
$payload = jwt_verify( $token );
if ( ! $payload ) {
return new WP_Error( 'invalid_token', 'Token invalid or expired.', [ 'status' => 401 ] );
}
return (int) $payload['sub'];
}, 10 );
NOTE: Store AUTH_KEY (the JWT secret) in wp-config.php outside the web root and never hard-code it; rotate it immediately if it is ever exposed, since all previously issued tokens will become invalid after rotation — build a grace-period refresh flow if you need zero-downtime key rotation.