Skip to main content

Overview

Laravel Breeze API + Next.js uses Laravel Sanctum for API authentication. Sanctum provides a lightweight authentication system for SPAs (Single Page Applications) using cookie-based authentication, which offers better security than traditional token-based approaches.

Laravel Sanctum Configuration

Stateful Domains

Sanctum is configured to treat specific domains as “stateful,” meaning they can authenticate using cookies:
Backend/config/sanctum.php
return [
    'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
        '%s%s%s',
        'localhost,localhost:3000,127.0.0.1,127.0.0.1:3000,127.0.0.1:8000,::1',
        Sanctum::currentApplicationUrlWithPort(),
        env('FRONTEND_URL') ? ','.parse_url(env('FRONTEND_URL'), PHP_URL_HOST) : ''
    ))),

    'guard' => ['web'],

    'expiration' => null,

    'middleware' => [
        'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
        'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
        'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
    ],
];
The stateful configuration tells Sanctum which domains should receive session cookies for authentication.

Authentication Flow

SPA Authentication Process

1

Request CSRF Token

Before making any authentication request, the frontend must obtain a CSRF token:
const csrf = () => axios.get('/sanctum/csrf-cookie')
This endpoint sets a XSRF-TOKEN cookie that protects against CSRF attacks.
2

Submit Credentials

The user submits their credentials (email and password) to the backend:
Frontend/src/hooks/auth.ts
const login = async (data: {
  email: string
  password: string
  remember: boolean
}) => {
  try {
    await csrf()
    await axios.post('/login', data)
    mutate()
  } catch (error) {
    throw error
  }
}
3

Backend Validates Credentials

Laravel validates the credentials and creates a session:
Backend/app/Http/Controllers/Auth/AuthenticatedSessionController.php
public function store(LoginRequest $request): Response
{
    $request->authenticate();

    $request->session()->regenerate();

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

Session Cookie Set

Laravel sets an encrypted session cookie that will be automatically included in subsequent requests.
5

Frontend Updates State

The frontend fetches the authenticated user data and updates the application state.

Authentication Hook

The useAuth hook provides a convenient interface for authentication operations:
Frontend/src/hooks/auth.ts
import useSWR from 'swr'
import axios from '@/lib/axios'
import { useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'

export const useAuth = ({
  middleware,
  redirectIfAuthenticated,
}: {
  middleware?: string
  redirectIfAuthenticated?: string
}) => {
  const router = useRouter()
  const params = useParams()

  const {
    data: user,
    error,
    mutate,
  } = useSWR('/api/user', () =>
    axios
      .get('/api/user')
      .then(res => res.data)
      .catch(error => {
        if (error.response.status !== 409) throw error

        router.push('/verify-email')
      }),
  )

  const csrf = () => axios.get('/sanctum/csrf-cookie')

  const login = async (data: {
    email: string
    password: string
    remember: boolean
  }) => {
    try {
      await csrf()
      await axios.post('/login', data)
      mutate()
    } catch (error) {
      throw error
    }
  }

  const logout = async () => {
    if (!error) {
      await axios.post('/logout').then(() => mutate())
    }

    window.location.pathname = '/login'
  }

  useEffect(() => {
    if (middleware === 'guest' && redirectIfAuthenticated && user) {
      router.push(redirectIfAuthenticated)
    }

    if (middleware === 'auth' && error) logout()
  }, [user, error, middleware, redirectIfAuthenticated])

  return {
    user,
    register,
    login,
    forgotPassword,
    resetPassword,
    resendEmailVerification,
    logout,
  }
}

Hook Features

Automatic User Fetching

Uses SWR to automatically fetch and cache user data

Middleware Protection

Supports auth and guest middleware for route protection

Auto Redirect

Automatically redirects based on authentication state

CSRF Handling

Automatically handles CSRF token requests

Backend Authentication Routes

All authentication routes are defined in the backend:
Backend/routes/auth.php
Route::post('/register', [RegisteredUserController::class, 'store'])
    ->middleware('guest')
    ->name('register');

Route::post('/login', [AuthenticatedSessionController::class, 'store'])
    ->middleware('guest')
    ->name('login');

Route::post('/forgot-password', [PasswordResetLinkController::class, 'store'])
    ->middleware('guest')
    ->name('password.email');

Route::post('/reset-password', [NewPasswordController::class, 'store'])
    ->middleware('guest')
    ->name('password.store');

Route::get('/verify-email/{id}/{hash}', VerifyEmailController::class)
    ->middleware(['auth', 'signed', 'throttle:6,1'])
    ->name('verification.verify');

Route::post('/email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
    ->middleware(['auth', 'throttle:6,1'])
    ->name('verification.send');

Route::post('/logout', [AuthenticatedSessionController::class, 'destroy'])
    ->middleware('auth')
    ->name('logout');

User Registration

The registration process creates a new user and automatically logs them in:

Frontend Implementation

Frontend/src/hooks/auth.ts
const register = async (data: {
  name: string
  email: string
  password: string
  password_confirmation: string
}) => {
  try {
    await csrf()
    await axios.post('/register', data)
    mutate()
  } catch (error) {
    throw error
  }
}

Backend Implementation

Backend/app/Http/Controllers/Auth/RegisteredUserController.php
public function store(Request $request): Response
{
    $request->validate([
        'name' => ['required', 'string', 'max:255'],
        'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
        'password' => ['required', 'confirmed', Rules\Password::defaults()],
    ]);

    $user = User::create([
        'name' => $request->name,
        'email' => $request->email,
        'password' => Hash::make($request->string('password')),
    ]);

    event(new Registered($user));

    Auth::login($user);

    return response()->noContent();
}

Login with Rate Limiting

The login process includes built-in rate limiting to prevent brute force attacks:
Backend/app/Http/Requests/Auth/LoginRequest.php
public function authenticate(): void
{
    $this->ensureIsNotRateLimited();

    if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
        RateLimiter::hit($this->throttleKey());

        throw ValidationException::withMessages([
            'email' => __('auth.failed'),
        ]);
    }

    RateLimiter::clear($this->throttleKey());
}

public function ensureIsNotRateLimited(): void
{
    if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
        return;
    }

    event(new Lockout($this));

    $seconds = RateLimiter::availableIn($this->throttleKey());

    throw ValidationException::withMessages([
        'email' => trans('auth.throttle', [
            'seconds' => $seconds,
            'minutes' => ceil($seconds / 60),
        ]),
    ]);
}
After 5 failed login attempts, the user will be temporarily locked out. This protects against brute force attacks.

Logout Process

Logout invalidates the session and clears authentication cookies:

Frontend

Frontend/src/hooks/auth.ts
const logout = async () => {
  if (!error) {
    await axios.post('/logout').then(() => mutate())
  }

  window.location.pathname = '/login'
}

Backend

Backend/app/Http/Controllers/Auth/AuthenticatedSessionController.php
public function destroy(Request $request): Response
{
    Auth::guard('web')->logout();

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

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

    return response()->noContent();
}

Protected Routes

Protected API routes require authentication via Sanctum middleware:
Backend/routes/api.php
Route::middleware(['auth:sanctum'])->get('/user', function (Request $request) {
    return $request->user();
});

Frontend Route Protection

The frontend uses layout middleware to protect authenticated routes:
Frontend/src/app/(authenticated)/layout.tsx
'use client'
import { ReactNode } from 'react'
import { useAuth } from '@/hooks/auth'
import Navigation from '@/components/Layouts/Navigation'

const AppLayout = ({ children }: { children: ReactNode }) => {
  const { user } = useAuth({ middleware: 'auth' })

  return (
    <div className="min-h-screen bg-gray-100">
      <Navigation user={user} />
      <main>{children}</main>
    </div>
  )
}

export default AppLayout
The middleware: 'auth' parameter automatically redirects unauthenticated users to the login page.
Laravel Sanctum uses cookie-based authentication for SPAs instead of traditional bearer tokens:
HTTP-only cookies cannot be accessed by JavaScript, protecting against XSS attacks.
Laravel’s CSRF protection works seamlessly with cookie-based authentication.
Cookies enable proper session management with session regeneration on login.
Browsers automatically handle cookie storage and transmission.
Ensure the Axios client is configured to send cookies:
Frontend/src/lib/axios.ts
const axios: AxiosInstance = Axios.create({
  baseURL: process.env.NEXT_PUBLIC_BACKEND_URL,
  headers: {
    'X-Requested-With': 'XMLHttpRequest',
  },
  withCredentials: true,  // Enable sending cookies
  withXSRFToken: true,    // Automatically send XSRF token
})

Password Reset Flow

The password reset flow involves multiple steps:
1

Request Reset Link

User submits email to receive a password reset link.
const forgotPassword = async (data: { email: string }) => {
  await csrf()
  return await axios.post('/forgot-password', data)
}
2

Receive Email

Laravel sends an email with a signed URL containing a reset token.
3

Submit New Password

User clicks the link and submits a new password.
const resetPassword = async (data: {
  email: string
  password: string
  password_confirmation: string
}) => {
  await csrf()
  const response = await axios.post('/reset-password', {
    ...data,
    token: params.token,
  })
  router.push('/login?reset=' + btoa(response.data.status))
}
4

Password Updated

Laravel validates the token and updates the password.

Email Verification

Email verification ensures users have access to their registered email:
Frontend/src/hooks/auth.ts
const resendEmailVerification = async () => {
  try {
    return await axios.post('/email/verification-notification')
  } catch (error) {
    throw error
  }
}
The useAuth hook automatically redirects unverified users:
useSWR('/api/user', () =>
  axios
    .get('/api/user')
    .then(res => res.data)
    .catch(error => {
      if (error.response.status !== 409) throw error
      router.push('/verify-email')  // Redirect to verification page
    }),
)

Best Practices

Always Use HTTPS

Use HTTPS in production to protect cookies and credentials in transit.

Configure CORS Properly

Ensure allowed_origins and stateful domains match your frontend URL.

Handle Token Refresh

Implement token refresh logic for long-lived sessions.

Secure Password Requirements

Use Laravel’s password validation rules for strong passwords.

Troubleshooting

  • Verify CORS configuration in config/cors.php
  • Check SANCTUM_STATEFUL_DOMAINS environment variable
  • Ensure cookies are being sent with withCredentials: true
  • Call /sanctum/csrf-cookie before authentication requests
  • Verify withXSRFToken: true in Axios configuration
  • Check that cookies are enabled in the browser
  • Verify SESSION_DOMAIN matches your frontend domain
  • Check that SESSION_DRIVER is set to cookie or database
  • Ensure supports_credentials: true in CORS config

Next Steps

Architecture Overview

Understand the full-stack architecture

Project Structure

Explore the codebase organization

Build docs developers (and LLMs) love