Mode token di chapter-chapter sebelumnya menuntut client menyimpan string rahasia — beban yang wajar untuk aplikasi mobile, tetapi canggung untuk SPA Vue atau React yang kita kendalikan sendiri. Menaruh token di localStorage membuka celah pencurian lewat skrip pihak ketiga (XSS), sementara SPA kita sesungguhnya hidup di domain yang sama dengan API. Untuk kasus itu Sanctum menyediakan mode kedua: autentikasi SPA berbasis cookie session. Chapter ini membedah alur lengkapnya — mulai dari /sanctum/csrf-cookie, login, hingga request API — semua dibuktikan dengan request sungguhan di sandbox.

Konsep: Session untuk Frontend First-Party #

Mode SPA mendaur ulang session Laravel yang sudah kita pahami dari section Authentication . Yang membedakannya: request API biasanya melewati middleware group api yang stateless — tanpa StartSession, tanpa cookie, tanpa CSRF. Sanctum menjembataninya lewat middleware EnsureFrontendRequestsAreStateful: middleware ini memeriksa header Referer request, dan bila host-nya terdaftar sebagai domain stateful, seluruh pipeline session (enkripsi cookie, StartSession, verifikasi CSRF) dijalankan untuk request API tersebut seolah-olah request web.

Syarat satu-satunya: frontend dan backend harus berbagi top-level domain yang sama — misalnya localhost:3000 (Vite dev server) dan localhost:8000 (Laravel), atau app.example.com dan api.example.com. Domain frontend didaftarkan lewat environment variable:

dotenv
SANCTUM_STATEFUL_DOMAINS=localhost:3000,127.0.0.1:3000

Tanpa variabel ini, Sanctum memakai daftar bawaan localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1 ditambah host aplikasi sendiri. Langkah wiring di sisi Laravel hanya satu baris di bootstrap/app.php:

php
<?php

// bootstrap/app.php (cuplikan)

->withMiddleware(function (Middleware $middleware): void {
    $middleware->statefulApi();
})

Pemanggilan statefulApi() menyisipkan EnsureFrontendRequestsAreStateful di urutan paling depan middleware group api.

Sanctum otomatis mendaftarkan satu endpoint khusus. Kita bisa memastikannya lewat php artisan route:list:

text
GET|HEAD  sanctum/csrf-cookie sanctum.csrf-cookie › Laravel\Sanctum › CsrfC…

Endpoint ini menetapkan cookie XSRF-TOKEN — nilai terenkripsi yang dibaca JavaScript. Setelah cookie itu ada, alur SPA berjalan tiga langkah. Pertama, GET /sanctum/csrf-cookie. Kedua, POST /login dengan header X-XSRF-TOKEN berisi cookie tadi (perilaku default axios ketika berada di domain yang sama). Ketiga, semua request berikutnya cukup mengandalkan cookie session yang terpasang otomatis. Route login-nya kita definisikan sebagai route web biasa:

php
<?php

// routes/web.php (cuplikan)

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;

Route::post('/login', function (Request $request) {
    $credentials = $request->validate([
        'email' => ['required', 'email'],
        'password' => ['required'],
    ]);

    if (Auth::guard('web')->attempt($credentials)) {
        $request->session()->regenerate();

        return response()->json(['message' => 'Login berhasil.']);
    }

    return response()->json([
        'message' => 'Email atau password salah.',
        'errors' => ['email' => ['Email atau password salah.']],
    ], 422);
});

Kita jalankan ketiga langkah tersebut di sandbox, dengan Referer mengarah ke domain stateful dan client cookie jar meniru perilaku browser:

Output yang Dihasilkan:

text
### 1. GET /sanctum/csrf-cookie → HTTP 204
Cookie yang tersimpan: XSRF-TOKEN, laravel-session

### 2. POST /login (header X-XSRF-TOKEN terpasang) → HTTP 200
{"message":"Login berhasil."}

### 3. GET /api/user (session cookie) → HTTP 200
{
    "id": 1,
    "name": "Budi",
    "email": "[email protected]",
    "email_verified_at": null,
    "created_at": "2026-01-02T09:00:00.000000Z",
    "updated_at": "2026-01-02T09:00:00.000000Z"
}

Endpoint /api/user di atas dilindungi auth:sanctum, sama seperti mode token. Bedanya: sanctum guard tidak menemukan bearer token, lalu mencoba guard web (sesuai konfigurasi 'guard' => ['web'] dari chapter Instalasi dan Konfigurasi ) — dan session yang aktif mengembalikan Budi. Cookie session-lah kredensialnya sekarang, bukan string token.

Batas Stateful: Domain Lain Ditolak #

Pertanyaan penting: apa yang terjadi bila request datang tanpa Referer dari domain stateful? Middleware EnsureFrontendRequestsAreStateful membiarkannya melewati pipeline stateless — cookie session tidak pernah diproses, dan endpoint terlindungi menolak dengan 401. Kita kunci perilaku ini sebagai feature test, dengan session cookie yang dikirim eksplisit tetapi tanpa header stateful:

php
<?php

// tests/Feature/Sanctum/SpaAuthTest.php (cuplikan)

public function test_session_tanpa_referer_stateful_tidak_bisa_masuk_api(): void
{
    User::factory()->create([
        'name' => 'Budi',
        'email' => '[email protected]',
        'password' => 'rahasia-budi',
    ]);

    $this->withCredentials()->get('/sanctum/csrf-cookie', $this->spaHeaders);

    $login = $this->withCredentials()->post('/login', [
        'email' => '[email protected]',
        'password' => 'rahasia-budi',
    ], $this->spaHeaders);
    $login->assertStatus(200);

    // session cookie ada, tapi request TANPA Referer domain stateful
    $sessionCookie = $login->getCookie(config('session.cookie'), false)->getValue();

    // reset state guard & session supaya request berikutnya mewakili
    // proses PHP terpisah, seperti dua request nyata di produksi
    $this->app->make('auth')->forgetGuards();
    $this->app->forgetInstance('session');
    $this->app->forgetInstance('session.store');

    $response = $this->withUnencryptedCookies([config('session.cookie') => $sessionCookie])
        ->getJson('/api/user');

    $response->assertStatus(401);
    $response->assertJson(['message' => 'Unauthenticated.']);
}

Output yang Dihasilkan:

text
  PASS  Tests\Feature\Sanctum\SpaAuthTest
  ✓ spa mendapatkan csrf cookie
  ✓ alur lengkap login spa lalu akses api
  ✓ login gagal password salah
  ✓ session tanpa referer stateful tidak bisa masuk api

  Tests:    4 passed (10 assertions)

Catatan CORS #

Bila SPA dilayani dari origin yang berbeda (port berbeda pun dihitung origin berbeda oleh browser), request pertama perlu izin CORS. Laravel menangani lewat middleware HandleCors dan konfigurasi config/cors.php: kolom allowed_origins diisi domain frontend, supports_credentials wajib true agar cookie ikut terkirim lintas origin, dan client axios memerlukan withCredentials: true. Detail CORS sendiri berada di luar cakupan Sanctum.

| Aspek | Token (Bearer) | SPA cookie (session) | | | | | | Penyimpanan kredensial | Client (localStorage/secure storage) | Browser (cookie HttpOnly) | | Rentan XSS mencuri kredensial | Ya, bila tersimpan di JS | Tidak — cookie tak terbaca JavaScript | | Cocok untuk subdomain berbeda | Ya | Ya, selama satu top-level domain | | Cocok untuk aplikasi mobile | Ya | Tidak | | Butuh endpoint /sanctum/csrf-cookie | Tidak | Ya |

Mode cookie menang untuk SPA first-party; mode token unggul begitu client keluar dari keluarga domain kita. Dua mode ini berbagi satu kebutuhan operasional: halaman untuk membuat, melihat, dan mencabut token — kebutuhan yang kita penuhi di chapter API Token Management . Alur resminya terdokumentasi di dokumentasi Sanctum — SPA authentication .