Kasus yang sering membingungkan saat memakai Laravel Sanctum adalah: proses login berhasil, cookie terlihat terset, tetapi setiap request API dari subdomain tetap mendapat 401 Unauthorized. Gejala ini umum terjadi pada arsitektur seperti frontend di app.example.com dan API di api.example.com, terutama saat pindah dari lokal ke staging atau production.

Masalahnya biasanya bukan pada endpoint login itu sendiri, melainkan pada cara browser mengirim cookie sesi lintas subdomain, bagaimana Laravel menandai request sebagai stateful, dan apakah konfigurasi CORS, domain cookie, SameSite, serta HTTPS sudah selaras. Di artikel ini, fokusnya adalah membedah konfigurasi yang paling sering menjadi penyebab: SANCTUM_STATEFUL_DOMAINS, SESSION_DOMAIN, CORS, SameSite, dan cookie secure.

Memahami akar masalah: login sukses, API tetap 401

Pada mode SPA, Sanctum tidak bekerja seperti token bearer biasa. Sanctum mengandalkan session cookie milik Laravel. Alur sederhananya seperti ini:

  1. Frontend memanggil endpoint CSRF cookie, biasanya /sanctum/csrf-cookie.
  2. Browser menerima cookie seperti XSRF-TOKEN dan cookie sesi.
  3. Frontend melakukan login ke endpoint login.
  4. Setelah login sukses, browser harus terus mengirim cookie sesi saat memanggil endpoint API yang dilindungi.

Kalau langkah 4 gagal, API akan menganggap user belum terautentikasi dan mengembalikan 401. Penyebab gagalnya langkah ini biasanya salah satu dari berikut:

  • Subdomain frontend tidak masuk ke SANCTUM_STATEFUL_DOMAINS.
  • SESSION_DOMAIN salah, sehingga cookie tidak berlaku untuk subdomain yang dibutuhkan.
  • Request lintas origin tidak mengirim kredensial karena konfigurasi CORS atau fetch/axios salah.
  • Cookie ditolak browser karena atribut SameSite atau Secure tidak cocok dengan skenario HTTPS.
  • Environment staging/production berada di belakang proxy sehingga Laravel salah mendeteksi skema HTTP/HTTPS.

Apa arti stateful pada Sanctum?

Sanctum membedakan request stateful dan stateless. Untuk SPA yang berjalan di domain milik kita sendiri, Sanctum mengharapkan request dianggap stateful, artinya autentikasi dilakukan lewat session cookie. Daftar origin/domain yang boleh diperlakukan seperti ini ditentukan oleh SANCTUM_STATEFUL_DOMAINS.

Jika frontend Anda berjalan di app.example.com tetapi nilai SANCTUM_STATEFUL_DOMAINS hanya berisi localhost, maka request dari subdomain itu bisa dianggap bukan request stateful. Akibatnya, meskipun cookie ada, middleware Sanctum tidak memprosesnya seperti yang diharapkan.

Contoh konfigurasi .env

SANCTUM_STATEFUL_DOMAINS=app.example.com,admin.example.com,staging-app.example.com
SESSION_DOMAIN=.example.com
SESSION_DRIVER=cookie
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax
APP_URL=https://api.example.com

Beberapa catatan penting:

  • SANCTUM_STATEFUL_DOMAINS berisi host frontend yang mengakses API, bukan host API itu sendiri semata.
  • SESSION_DOMAIN=.example.com dengan titik di depan membuat cookie dapat dipakai lintas subdomain seperti app.example.com dan api.example.com.
  • Untuk staging yang memakai domain berbeda, misalnya staging.example.net, Anda harus menyesuaikan SESSION_DOMAIN dan SANCTUM_STATEFUL_DOMAINS untuk domain tersebut. Jangan mengasumsikan satu konfigurasi cocok untuk semua lingkungan.

Kesalahan umum pada SANCTUM_STATEFUL_DOMAINS

  • Memasukkan URL lengkap dengan skema, misalnya https://app.example.com. Umumnya yang dibutuhkan adalah host dan bila perlu port, bukan URL penuh.
  • Lupa menambahkan port saat development, misalnya localhost:3000.
  • Hanya memasukkan domain API, padahal frontend berjalan di host lain.
  • Sudah mengubah .env, tetapi belum menjalankan php artisan config:clear atau php artisan optimize:clear.

SESSION_DOMAIN: penentu apakah cookie bisa dibaca lintas subdomain

Jika login berhasil tetapi cookie sesi hanya berlaku untuk api.example.com, browser tidak akan mengirimkannya saat konteks request berasal dari subdomain lain dengan aturan cookie tertentu. Karena itu, untuk skenario subdomain dalam satu root domain yang sama, konfigurasi paling umum adalah:

SESSION_DOMAIN=.example.com

Dengan ini, cookie sesi dapat digunakan oleh seluruh subdomain di bawah example.com. Jika Anda menulis SESSION_DOMAIN=api.example.com, cookie menjadi terlalu sempit cakupannya.

Jika frontend dan backend berada pada root domain yang benar-benar berbeda, misalnya frontend.example.com dan api.example.org, masalahnya bukan lagi sekadar subdomain. Aturan cookie browser akan lebih ketat, dan pendekatannya harus dievaluasi ulang.

Verifikasi domain cookie di DevTools

Buka browser DevTools, lalu cek tab Application atau Storage, bagian Cookies. Perhatikan:

  • Apakah cookie sesi benar-benar ada?
  • Nilai Domain-nya apakah .example.com atau terlalu spesifik?
  • Path biasanya /.
  • Apakah cookie bertanda Secure?
  • Apakah atribut SameSite sesuai dengan skenario Anda?

CORS dan credentials: cookie tidak akan terkirim jika ini salah

Banyak kasus 401 ternyata bukan karena Sanctum, melainkan browser tidak pernah mengirim cookie ke API. Untuk request lintas origin, Anda harus mengizinkan credentials di dua sisi:

  1. Frontend harus mengirim request dengan withCredentials atau credentials: 'include'.
  2. Backend harus mengizinkan kredensial lewat konfigurasi CORS.

Contoh frontend dengan Axios

import axios from 'axios';

axios.defaults.withCredentials = true;
axios.defaults.withXSRFToken = true;

await axios.get('https://api.example.com/sanctum/csrf-cookie');
await axios.post('https://api.example.com/login', {
  email: 'user@example.com',
  password: 'secret'
});

const response = await axios.get('https://api.example.com/api/user');

Contoh konfigurasi CORS Laravel

return [
    'paths' => ['api/*', 'login', 'logout', 'sanctum/csrf-cookie'],
    'allowed_methods' => ['*'],
    'allowed_origins' => ['https://app.example.com'],
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['*'],
    'exposed_headers' => [],
    'max_age' => 0,
    'supports_credentials' => true,
];

Hal penting di sini:

  • supports_credentials harus true.
  • Jika memakai credentials, jangan gunakan * untuk allowed_origins. Browser akan menolak kombinasi itu.
  • Pastikan path seperti sanctum/csrf-cookie, login, dan endpoint API Anda memang termasuk dalam CORS config.

SameSite dan Secure cookie di staging/production

Pada lingkungan modern, browser sangat ketat terhadap cookie. Dua atribut yang sering berpengaruh adalah SameSite dan Secure.

SameSite

Untuk skenario frontend dan API yang masih berada di satu situs utama dengan subdomain berbeda, SESSION_SAME_SITE=lax sering cukup. Namun, perilaku browser bergantung pada konteks request dan cara aplikasi dijalankan. Jika arsitektur Anda benar-benar dianggap lintas situs oleh browser atau ada integrasi melalui iframe/cross-site request tertentu, SameSite=None mungkin diperlukan.

Tetapi ada aturan penting: jika memakai SameSite=None, cookie harus Secure. Artinya hanya akan dikirim lewat HTTPS.

SESSION_SAME_SITE=lax
SESSION_SECURE_COOKIE=true

Atau jika memang perlu:

SESSION_SAME_SITE=none
SESSION_SECURE_COOKIE=true

Secure cookie

Di staging/production yang sudah HTTPS, sangat disarankan memakai:

SESSION_SECURE_COOKIE=true

Kalau nilainya true tetapi aplikasi sebenarnya diakses melalui HTTP, cookie tidak akan terkirim. Sebaliknya, jika production sudah HTTPS tetapi cookie tidak ditandai secure, Anda bisa menemui perilaku yang tidak konsisten dan risiko keamanan lebih besar.

Waspadai reverse proxy atau load balancer

Bila Laravel berada di belakang Nginx proxy, ingress Kubernetes, Cloudflare, atau load balancer, Laravel harus bisa mendeteksi bahwa request asli datang melalui HTTPS. Jika tidak, aplikasi mungkin menghasilkan cookie atau redirect yang tidak konsisten.

Pastikan header proxy diteruskan dengan benar dan konfigurasi trusted proxies sesuai. Gejala klasiknya: browser menerima cookie, tetapi atributnya tidak seperti yang diharapkan, atau redirect bolak-balik antara HTTP dan HTTPS.

Contoh konfigurasi yang umum benar untuk subdomain

Misalkan arsitekturnya seperti ini:

  • Frontend SPA: https://app.example.com
  • API Laravel: https://api.example.com

.env pada API:

APP_URL=https://api.example.com
FRONTEND_URL=https://app.example.com

SANCTUM_STATEFUL_DOMAINS=app.example.com
SESSION_DOMAIN=.example.com
SESSION_DRIVER=cookie
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax

config/cors.php:

return [
    'paths' => ['api/*', 'login', 'logout', 'sanctum/csrf-cookie'],
    'allowed_methods' => ['*'],
    'allowed_origins' => ['https://app.example.com'],
    'allowed_headers' => ['*'],
    'exposed_headers' => [],
    'max_age' => 0,
    'supports_credentials' => true,
];

Frontend harus mengirim credentials:

fetch('https://api.example.com/api/user', {
  method: 'GET',
  credentials: 'include',
  headers: {
    'Accept': 'application/json'
  }
});

Setelah mengubah konfigurasi, jalankan:

php artisan optimize:clear

Checklist debug di browser DevTools

Jika masih 401, lakukan pengecekan ini secara sistematis. Jangan menebak-nebak; lihat apa yang benar-benar dikirim browser.

1. Cek request ke /sanctum/csrf-cookie

  • Status harus sukses.
  • Response header harus berisi Set-Cookie.
  • Cookie XSRF-TOKEN dan sesi harus tersimpan.

2. Cek request login

  • Pastikan request membawa kredensial.
  • Pastikan response login juga menyetel atau memperbarui cookie sesi.
  • Periksa apakah ada warning seperti cookie blocked, This Set-Cookie was blocked..., atau masalah SameSite/Secure.

3. Cek request ke endpoint API yang 401

  • Apakah request header memiliki Cookie?
  • Apakah origin sesuai dengan yang diizinkan CORS?
  • Apakah response CORS menyertakan Access-Control-Allow-Credentials: true?

4. Cek tab Cookies di DevTools

  • Domain cookie benar?
  • Secure aktif saat HTTPS?
  • SameSite cocok?
  • Cookie expired atau terhapus?

5. Cek konfigurasi server

  • HTTPS benar-benar aktif end-to-end?
  • Proxy meneruskan header HTTPS dengan benar?
  • Tidak ada redirect aneh antar-subdomain?

Kesalahan yang paling sering terjadi

  • SANCTUM_STATEFUL_DOMAINS belum memasukkan domain frontend staging/production.
  • SESSION_DOMAIN terlalu sempit, misalnya hanya api.example.com.
  • CORS masih memakai wildcard padahal request mengirim credentials.
  • Frontend lupa withCredentials atau credentials: 'include'.
  • SESSION_SECURE_COOKIE=true dipakai pada lingkungan yang belum HTTPS penuh.
  • SameSite=None dipakai tanpa Secure, sehingga cookie diblokir browser.
  • Perubahan env/config belum diterapkan karena cache config masih aktif.

Penutup

Jika login Sanctum berhasil tetapi request API dari subdomain tetap 401, hampir selalu akar masalahnya ada pada cookie yang tidak ikut terkirim atau tidak dikenali sebagai stateful request. Karena itu, fokus debugging sebaiknya dimulai dari lima titik: SANCTUM_STATEFUL_DOMAINS, SESSION_DOMAIN, CORS credentials, SameSite, dan Secure cookie.

Pendekatan terbaik bukan sekadar mengubah config secara acak, tetapi memverifikasi alur di browser DevTools: apakah cookie disetel, apakah browser menyimpannya, dan apakah cookie benar-benar terkirim pada request yang gagal. Begitu alur ini terlihat jelas, penyebab 401 biasanya cepat ditemukan.