Login system in PHP using AJAX I am beginning the cycle of creating my role based access control into my framework. I now want to log the user into my application and my _user table looks like this:
CREATE TABLE `_users` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(255) NOT NULL,
  `hash` varchar(255) NOT NULL,
  `session` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
)

I create my user like so in my model, where bin2hex() is my IV / salt.
Database::getInstance()-&gt;Prepare( 'INSERT INTO _users (username, hash, session) VALUES (?, ?, ?)' )
                       -&gt;execute( array(
                           'Test',
                           password_hash( 'Test', PASSWORD_BCRYPT ),
                           md5( time() . bin2hex( random_bytes( 32 ) ) )
                       ) );

To then log into the user, I use a method. The method I use in the request is POST because my application has built in CRSF tokens. If the CRSF token is passed, then a xauth_protected aliased class is instanced which ensures that X-Auth header exists and is of md5(session_id()) to ensure that no cross-site-origin requests can be made.
public function login( Request $request )
{
    $this-&gt;middleware( 'json_response', $request );
    $this-&gt;middleware( 'xauth_protected', $request );
    
    if( !$request-&gt;has( 'username' ) || !$request-&gt;has( 'password') )
    {
        echo json_encode( array( 
            'status' =&gt; FALSE,
            'reason' =&gt; 'Please fill in the required fields...',
        ) );
        return;
    }
    
    $stmt = Database::getInstance()-&gt;Prepare( 'SELECT id, hash, session FROM _users WHERE username = ? LIMIT 1' );
    $stmt-&gt;execute( array( $request-&gt;username ) );
    $row  = (object) $stmt-&gt;fetch();
    
    if( isset( $row-&gt;id ) )
    {
        if( password_verify( $request-&gt;password, $row-&gt;hash ) )
        {
            // $_SESSION['oath'] = $row-&gt;session
            // This is used to find the user that is logged in, if it is set
            $request-&gt;setSession( 'oauth', $row-&gt;session );
            
            echo json_encode( array( 
                'status' =&gt; TRUE,
            ) );
            
            return;
        }
        
        echo json_encode( array( 
            'status' =&gt; FALSE,
            'reason' =&gt; 'Invalid credentials...',
        ) );
        
        return;
    }
    
    echo json_encode( array( 
            'status' =&gt; FALSE,
            'reason' =&gt; 'Sorry, that username was not found in our records...',
        ) );
}

To then test this method, I can use:
const formData = new FormData();
    
formData.append( 'crsf_token', sessionStorage.getItem( 'token' ) ); // CRSF token is set elsewhere
formData.append( 'username', document.getElementById( 'username' ).value ); // Example input field
formData.append( 'password', document.getElementById( 'password' ).value ); // Example input field

fetch( App.__viewFactory.homepage.login, { // This is a prestored route that looks like /oauth/login
    method: 'POST',
    headers: {
        'X-Auth': sessionStorage.getItem( 'session' ) // md5( session_id() ) which is set elsewhere
    },
    body: formData
} )
.then( response =&gt; response.json() )
.then( data     =&gt; {
    if( data.result ) {
        window.location.href = App.__viewFactory.dashboard.view; // prestored route to the dashboard
        return;
    }
        
    document.getElementById( 'login-error' ).innerHTML = data.reason;
} )
.catch(error =&gt; {
    document.getElementById( 'login-error' ).innerHTML = 'Oh no! Something went wrong.';
});

I have incorporated CRSF protected, custom headers using the session_id() to prevent cross-origin requests (since my framework rewrites all requests to my index page, this then turns on CORS). Is everything I am doing on the database side as secure? Is there something I could be doing better?
Your querying practices look secure to me.  Your are using a prepared statement with bound parameters and using password_hash() and password_verify().  It really can be as simple as doing those basic/essential things.
As for other refinements, I recommend writing the failure branches before successful ones, doing early exit()s, and only passing back an empty or populated reason to reduce the data structure to its vital value.

I will assume/hope that the username is a UNIQUE table column, so LIMIT 1 provides no value.
If you want the result set / row to be an object, just tell pdo that that is what you want -- fetch(PDO::FETCH_OBJ).
The result set will either be an object or false, so just check for a falsey result set.  For this reason, you can remove id from the SELECT.
I don't like to give too much specificity when giving failed login responses.  I would tell the user that the credentials generally failed without spelling out which field was the problem.

Recommendation:
public function login(Request $request)
{
    $this-&gt;middleware('json_response', $request);
    $this-&gt;middleware('xauth_protected', $request);
    
    if(!$request-&gt;has('username') || !$request-&gt;has('password')) {
        exit(json_encode(['reason' =&gt; 'Please fill in the required fields']);
    }
    
    $stmt = Database::getInstance()-&gt;Prepare(
        &quot;SELECT hash, session FROM _users WHERE username = ?&quot;
    );
    $stmt-&gt;execute([$request-&gt;username]);
    $rowObject = $stmt-&gt;fetch(PDO::FETCH_OBJ);
    
    if (!$rowObject || !password_verify($request-&gt;password, $rowObject-&gt;hash)) {
        exit(json_encode(['reason' =&gt; 'Invalid credentials']));
    }

    $request-&gt;setSession('oauth', $rowObject-&gt;session);
    exit(json_encode(['reason' =&gt; null]));
}

Then in your js, you can use this:
.then(data =&gt; {
    if (data.reason) {
        document.getElementById('login-error').innerHTML = data.reason;
    } else {
        window.location.href = App.__viewFactory.dashboard.view;
    }
})