Уся автентифікація Laravel збирається з двох деталей, описаних у config/auth.php. Guard відповідає на питання, як пізнати користувача в цьому конкретному запиті: драйвер session читає ідентифікатор із сесії, token порівнює параметр api_token з колонкою в таблиці, sanctum і passport додають свої драйвери при встановленні пакетів. Provider заходить з іншого боку: звідки взяти користувача за цим ідентифікатором і чим перевірити пароль. Драйвер eloquent бере модель із ключа model і працює через EloquentUserProvider, драйвер database ходить у таблицю напряму. Інтерфейс UserProvider короткий (retrieveById, retrieveByToken, retrieveByCredentials, validateCredentials, updateRememberToken і доданий у Laravel 11 rehashPasswordIfRequired), тож власний provider для LDAP або зовнішнього сервісу - це один клас і Auth::provider('ldap', ...) у сервіс-провайдері. Для зовсім простих випадків є Auth::viaRequest('header-token', fn (Request $r) => ...), який робить guard з одного замикання.
Класичний веб-логін працює так: Auth::attempt(['email' => ..., 'password' => ...], $remember) просить provider знайти користувача за всіма переданими полями, крім password, перевіряє пароль через Hash::check() і за успіху кладе в сесію ключ login_web_<sha1 повного імені класу guard> зі значенням $user->getAuthIdentifier(). У самій сесії немає ні пароля, ні ролей - лише id, тому наступний запит щоразу читає користувача з БД. $remember додає cookie remember_web_<той самий хеш> із трьох частин: id, remember_token і хеш пароля. Звідси два практичні наслідки. Auth::logout() викликає cycleRememberToken(), тобто перезаписує remember_token, і всі старі remember-cookie стають непридатні; а middleware auth.session (AuthenticateSession) порівнює password_hash_web у сесії з поточним хешем пароля й вилогінює сесію, якщо пароль змінили. Без цього middleware Auth::logoutOtherDevices() не працює, і зміна пароля не вибиває зловмисника, який уже має живу сесію. Паролі ж хешуються bcrypt (або argon2id через config/hashing.php), а з Laravel 11 вмикається автоматичний rehash на логіні, коли cost у конфігу виріс.
Sanctum надбудовується над цією ж механікою й закриває два найчастіші випадки; плутанини тут найбільше. Перший режим - персональні токени: $user->createToken('Mobile', ['orders:read'], now()->addDays(30)) пише рядок у personal_access_tokens (поліморфний tokenable, json-колонка abilities, last_used_at, expires_at із Sanctum 3.2) і повертає plainTextToken виду 7|<секрет>. У базі лежить hash('sha256', $секрет), тому показати токен удруге неможливо за задумом. Другий - звичайна cookie-сесія для свого SPA: EnsureFrontendRequestsAreStateful, доданий через $middleware->statefulApi(), для запитів із домену зі списку sanctum.stateful вмикає StartSession і ValidateCsrfToken, а guard sanctum спершу питає web-guard і лише потім дивиться на Authorization: Bearer. Тому Sanctum у SPA-режимі не видає жодного токена: клієнт стукає на /sanctum/csrf-cookie, отримує XSRF-TOKEN, логіниться звичайним POST на /login і далі живе на сесійному cookie, який HttpOnly і не дістається з JavaScript. Умови режиму жорсткі: спільний top-level домен, домен у sanctum.stateful, supports_credentials => true у config/cors.php і withCredentials на клієнті. Мобільний застосунок або чужий сервер цих умов не виконує, тому їм дають токени.
Passport вирішує іншу задачу. Це реалізація OAuth2-сервера на league/oauth2-server із власними таблицями клієнтів і токенів, RSA-ключами з passport:keys, підписаними JWT-доступами, refresh-токенами, екраном згоди на /oauth/authorize і scopes, які клієнт просить у протоколі. Він потрібен, коли логінитись у вас будуть сторонні застосунки: «увійти через наш сервіс», інтеграції партнерів, кілька незалежних продуктів навколо одного акаунта, machine-to-machine доступ через client credentials. Рекомендований флоу - authorization code з PKCE; password і implicit grant у Passport 11+ вимкнені за замовчуванням і включаються окремими Passport::enablePasswordGrant() та enableImplicitGrant(), бо OAuth 2.1 від них відмовився. Якщо чужих клієнтів немає, Passport додає лише роботу: ротацію ключів, прибирання токенів, довший цикл логіну й додатковий шар, у якому легко помилитися. Буває й обернена ситуація: коли ви самі хочете логінити людей через Google або GitHub, цим займається Socialite, а не Passport чи Sanctum.
Готовий фронтенд для входу тепер беруть зі стартових наборів Laravel 12: React і Vue на Inertia 2 або Livewire з Volt, усі з Tailwind 4, Pest і сторінками реєстрації, скидання пароля, підтвердження email і налаштувань профілю. Є варіанти на WorkOS AuthKit, де сам логін, соцмережі, passkeys і SSO живуть за межами застосунку. Breeze і Jetstream далі встановлюються, але документація більше не веде до них нові проєкти, і будувати на них свіжий застосунок означає підписатися на шлях, який уже не розвивають. Fortify лишається окремим інструментом для випадку «свій фронтенд, чужі контролери»: маршрути для логіну, 2FA й підтвердження пароля без жодного шаблону. Залишилася межа, яку легко загубити: автентифікація закінчується там, де починається авторизація. Guard каже, хто прийшов, abilities токена кажуть, яким ключем, а право на конкретний об'єкт перевіряє policy. Якщо tokenCan('orders:read') стоїть замість authorize('view', $order), будь-який власник токена читає чужі замовлення, і на співбесіді це видно одразу.
// config/auth.php: guard каже «як пізнати», provider - «де взяти й чим перевірити пароль»
'guards' => [
'web' => ['driver' => 'session', 'provider' => 'users'],
// окрема сесія: ключ login_admin_<sha1 класу> не конфліктує з web
'admin' => ['driver' => 'session', 'provider' => 'admins'],
// драйвер реєструє сам Sanctum: спершу web-сесія, потім Bearer-токен
'api' => ['driver' => 'sanctum', 'provider' => 'users'],
],
'providers' => [
'users' => ['driver' => 'eloquent', 'model' => App\Models\User::class],
'admins' => ['driver' => 'eloquent', 'model' => App\Models\Admin::class],
],
final class DeviceTokenController
{
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
'device_name' => ['required', 'string', 'max:100'],
]);
$user = User::where('email', $data['email'])->first();
// Hash::check читає алгоритм і cost із самого хеша; порівнювати рядки не можна
if (! $user || ! Hash::check($data['password'], $user->password)) {
throw ValidationException::withMessages(['email' => __('auth.failed')]);
}
// plainTextToken існує лише тут: у БД лежить sha256 від секрету
$token = $user->createToken(
$data['device_name'],
['orders:read'], // перевіряється через tokenCan() і middleware abilities
now()->addDays(30), // перебиває config('sanctum.expiration')
);
return response()->json(['token' => $token->plainTextToken], 201);
}
}
// Ability звужує права токена, policy всередині вирішує доступ до конкретного замовлення
Route::middleware(['auth:sanctum', 'abilities:orders:read'])
->get('/orders/{order}', [OrderController::class, 'show']);
Відповідайте через три питання, на які відповідає кожен шар: guard - «як пізнати користувача в цьому запиті», provider - «звідки його взяти», Sanctum - «токен чи сесія для мого ж клієнта». І додайте межу для Passport одним реченням: Passport потрібен, коли логінитись у вас будуть чужі застосунки, тобто коли вам потрібен екран згоди, `client_id` і refresh-токени. Якщо чужих клієнтів немає, OAuth2 - це сервер, який ви будете супроводжувати задарма.