Authentication remains the highest-risk layer in any web application. A single misconfiguration — an exposed token, a missing CSRF check, a permissive middleware — can compromise your entire system. In 2026, the Laravel + Vue stack has mature tooling to get auth right, but only if you wire things up deliberately. This post covers the architecture, patterns, and implementation details that keep auth secure without overcomplicating your codebase.
Recommended Architecture
1. Sanctum for SPA Auth
Laravel Sanctum is the default choice for SPA authentication. For same-domain setups (your Vue app served from the same domain or a subdomain), use Sanctum’s cookie-based session authentication — not API tokens.
// config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,127.0.0.1')),// Vue login call — cookie-based, no tokens to store
await axios.get('/sanctum/csrf-cookie');
await axios.post('/login', { email, password });Cookie-based auth avoids storing tokens in localStorage (which is vulnerable to XSS). Reserve API tokens for mobile apps, CLI tools, or third-party integrations where cookies aren’t an option.
2. CSRF Protection on State-Changing Routes
Every POST, PUT, PATCH, and DELETE request must include a valid CSRF token. Sanctum handles this through the /sanctum/csrf-cookie endpoint, which sets an XSRF-TOKEN cookie. Axios reads this cookie automatically when withCredentials is enabled.
// axios config
axios.defaults.withCredentials = true;
axios.defaults.withXSRFToken = true;Never disable CSRF protection for convenience. If a third-party webhook needs to hit your app, put it on a separate route group without the VerifyCsrfToken middleware — don’t strip protection from your entire app.
3. Clean Web vs API Middleware Boundaries
Keep your route groups separated. Web routes use session-based auth with CSRF. API routes use token-based auth (or Sanctum stateful auth for SPAs). Mixing them causes subtle bugs and security gaps.
// routes/web.php — session + CSRF
Route::middleware(['web', 'auth'])->group(function () {
Route::get('/dashboard', DashboardController::class);
});
// routes/api.php — Sanctum guard
Route::middleware(['auth:sanctum'])->group(function () {
Route::get('/user', fn (Request $request) => $request->user());
Route::apiResource('/posts', PostController::class);
});If your SPA uses Sanctum’s cookie auth, your API routes still go through the auth:sanctum middleware — Sanctum detects the session cookie and authenticates without a token.
4. Roles and Permissions via Policies and Gates
Use Laravel’s built-in authorization system. Gates handle simple ability checks. Policies handle resource-level authorization. Don’t reinvent this with custom middleware or ad-hoc if checks scattered through controllers.
// app/Policies/PostPolicy.php
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id || $user->hasRole('editor');
}
// Controller
public function update(Request $request, Post $post)
{
$this->authorize('update', $post);
// proceed with update
}For role management, a lightweight package like spatie/laravel-permission works well. Keep role logic in policies and gates — not in Vue components. The frontend can hide UI elements based on roles, but the backend must enforce every permission.
5. Token Revocation and Rotation for Multi-Device Control
When using API tokens, give users visibility and control over their active sessions. Sanctum stores tokens in the personal_access_tokens table, making revocation straightforward.
// Revoke a specific token
$user->tokens()->where('id', $tokenId)->delete();
// Revoke all tokens (e.g., on password change)
$user->tokens()->delete();
// Rotate: delete current, issue new
$request->user()->currentAccessToken()->delete();
$newToken = $request->user()->createToken('device-name');Set token expiration in config/sanctum.php:
'expiration' => 60 * 24 * 7, // 7 daysExpired tokens are rejected automatically. Run a scheduled command to prune old tokens from the database.
6. Rate Limiting on Auth Endpoints
Brute-force protection is non-negotiable. Laravel’s rate limiter makes this simple.
// app/Providers/AppServiceProvider.php
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
// routes/web.php
Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:login');Apply rate limits to login, registration, password reset, and any endpoint that accepts credentials. Return a 429 response — don’t silently drop requests.
7. Logging Suspicious Auth Events
Log failed logins, password resets, token creations, and privilege escalations. Laravel’s event system makes this clean.
// EventServiceProvider or event discovery
Event::listen(Failed::class, function (Failed $event) {
Log::warning('Failed login attempt', [
'email' => $event->credentials['email'] ?? 'unknown',
'ip' => request()->ip(),
]);
});
Event::listen(Login::class, function (Login $event) {
Log::info('User logged in', [
'user_id' => $event->user->id,
'ip' => request()->ip(),
]);
});Ship these logs to a monitoring tool. Alert on patterns: multiple failed attempts from one IP, logins from new geolocations, or password resets for admin accounts.
8. Optional MFA for Privileged Roles
For admin or editor roles, add a second authentication factor. Laravel Fortify provides TOTP (time-based one-time password) support out of the box.
// Require MFA confirmation for sensitive actions
Route::middleware(['auth', 'password.confirm'])->group(function () {
Route::put('/settings/security', [SecurityController::class, 'update']);
});For TOTP-based MFA, Fortify handles QR code generation, secret storage, and verification. On the Vue side, prompt for the code after the initial login succeeds but before granting full access. Don’t make MFA mandatory for all users unless your threat model requires it — forcing MFA on low-privilege accounts increases support burden without proportional security gain.
Vue Implementation Tips
Centralized Auth State
Keep authentication state in a Pinia store. Every component that needs to know the current user or check permissions reads from one place.
// stores/auth.js
import { defineStore } from 'pinia';
import axios from 'axios';
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
loaded: false,
}),
getters: {
isAuthenticated: (state) => !!state.user,
hasRole: (state) => (role) => state.user?.roles?.includes(role) ?? false,
},
actions: {
async fetchUser() {
try {
const { data } = await axios.get('/api/user');
this.user = data;
} catch {
this.user = null;
} finally {
this.loaded = true;
}
},
async logout() {
await axios.post('/logout');
this.user = null;
},
},
});Call fetchUser() once on app mount. Don’t scatter /api/user calls across components.
Graceful Session Expiry Handling
Sessions expire. Handle 401 responses globally with an Axios interceptor instead of letting individual components fail silently.
axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
const auth = useAuthStore();
auth.user = null;
router.push({ name: 'login', query: { expired: '1' } });
}
return Promise.reject(error);
}
);On the login page, check for the expired query param and show a message like “Your session expired. Please log in again.” This is better UX than a blank screen or a cryptic error.
Explicit Route Guards
Protect routes in Vue Router with navigation guards. Check the auth store before allowing navigation.
router.beforeEach(async (to) => {
const auth = useAuthStore();
if (!auth.loaded) {
await auth.fetchUser();
}
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } };
}
if (to.meta.requiredRole && !auth.hasRole(to.meta.requiredRole)) {
return { name: 'forbidden' };
}
});// Route definition
{
path: '/admin',
component: AdminPanel,
meta: { requiresAuth: true, requiredRole: 'admin' },
}Remember: route guards are a UX feature, not a security boundary. The backend must reject unauthorized requests regardless of what the frontend allows.
Conclusion
Authentication doesn’t need to be clever. Sanctum handles the hard parts — session management, CSRF, token issuance. Your job is to wire it up correctly: enforce CSRF on mutations, separate middleware groups, authorize at the policy level, rate-limit auth endpoints, and log what matters.
On the Vue side, centralize state, handle expiry gracefully, and guard routes explicitly. That’s it. No custom JWT implementations, no hand-rolled token storage, no elaborate middleware chains.
Ship auth that’s simple, secure, and production-ready. Then move on to the parts of your app that actually need your creativity.