Sanctum

Autentikasi token personal access dan mode SPA pada paket Sanctum.

Daftar isi

Sanctum adalah paket resmi Laravel untuk autentikasi pada API dan frontend terpisah. Paket ini menyediakan dua mekanisme, yaitu token personal access yang dikirim pada setiap permintaan dan cookie session untuk SPA (single page application) yang dikembangkan bersama aplikasi. Topik ini membahas persiapan model User, pembuatan token beserta kemampuannya, perlindungan rute dengan middleware auth:sanctum, pencabutan token, dan alur login SPA mulai dari cookie CSRF hingga permintaan yang terautentikasi. Seluruh skenario diuji dengan permintaan HTTP nyata pada aplikasi catatan.

Persiapan model dan tabel#

Perintah php artisan install:api yang dijalankan saat penyusunan rute API telah memasang Sanctum, menerbitkan migration tabel personal_access_tokens, dan membuat berkas config/sanctum.php. Keluaran perintah tersebut juga menyisipkan satu langkah yang masih harus dilakukan, yaitu menambahkan trait HasApiTokens pada model User. Trait inilah yang menyediakan metode tokens() untuk membaca seluruh token pengguna dan createToken() untuk membuat token baru.

bash
# menambahkan trait HasApiTokens pada model User
cd ~/aplikasi-catatan
cat > app/Models/User.php <<'PHP'
<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
    /** @use HasFactory<UserFactory> */
    use HasApiTokens, HasFactory, Notifiable;

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }
}
PHP

Tabel personal_access_tokens perlu dipastikan tersedia sebelum token dibuat. Blok berikut hanya menjalankan migration yang tertunda apabila tabelnya belum ada, sehingga aman dieksekusi ulang.

bash
# memastikan tabel personal_access_tokens sudah ada
cd ~/aplikasi-catatan
php artisan tinker --execute='
use Illuminate\Support\Facades\Schema;
if (! Schema::hasTable("personal_access_tokens")) {
    \Illuminate\Support\Facades\Artisan::call("migrate", ["--force" => true]);
    echo Artisan::output();
}
echo "tabel personal_access_tokens: ".(Schema::hasTable("personal_access_tokens") ? "ada" : "tidak ada").PHP_EOL;
'
text Keluaran
tabel personal_access_tokens: ada

Token personal access#

Token personal access dibuat dengan metode createToken() yang menerima nama token dan daftar kemampuan (ability). Keluarannya berupa objek NewAccessToken yang membawa model token serta teks token utuh pada properti plainTextToken. Teks utuh berformat {id}|{karakter acak} dan hanya tersedia pada saat pembuatan, karena tabel hanya menyimpan hash-nya. Setelah dibuat, teks token tidak dapat dibaca ulang dari database, dan satu-satunya cara mendapatkan teks baru adalah membuat token lagi.

bash
# membuat token personal access untuk pengguna uji
cd ~/aplikasi-catatan
php artisan tinker --execute='
use App\Models\User;
use Illuminate\Support\Facades\DB;
$pengguna = User::firstOrCreate(
    ["email" => "[email protected]"],
    ["name" => "Ali", "password" => "kunci-rahasia-ali"]
);
$pengguna->update(["name" => "Ali", "password" => "kunci-rahasia-ali"]);
$pengguna->tokens()->delete();
$token = $pengguna->createToken("uji-cli", ["profil:lihat"]);
echo "jumlah token milik Ali: ".$pengguna->tokens()->count().PHP_EOL;
echo "kemampuan token: ".implode(", ", $token->accessToken->abilities).PHP_EOL;
echo "panjang bagian acak token: ".strlen(explode("|", $token->plainTextToken, 2)[1])." karakter".PHP_EOL;
echo "token disamarkan: ".str_repeat("x", 8)."...".PHP_EOL;
$hash = DB::table("personal_access_tokens")->where("name", "uji-cli")->value("token");
echo "panjang hash tersimpan: ".strlen($hash)." karakter".PHP_EOL;
echo "tokenCan profil:lihat: ".var_export($token->accessToken->can("profil:lihat"), true).PHP_EOL;
'
text Keluaran
jumlah token milik Ali: 1
kemampuan token: profil:lihat
panjang bagian acak token: 48 karakter
token disamarkan: xxxxxxxx...
panjang hash tersimpan: 64 karakter
tokenCan profil:lihat: true

Keluaran tersebut menunjukkan bahwa token bernama uji-cli membawa satu kemampuan bernama profil:lihat, bagian acaknya sepanjang 48 karakter pada versi Sanctum yang terpasang, dan hash yang tersimpan sepanjang 64 karakter. Nilai plainTextToken yang sebenarnya terletak di antara kedua informasi tersebut, berformat {id}|{karakter acak}, dan hanya boleh dikirim satu kali kepada klien karena kelak digunakan sebagai kata sandi rahasia pada header permintaan.

Melindungi rute dengan auth:sanctum#

Rute yang menuntut autentikasi diberi middleware alias auth:sanctum. Middleware ini membaca teks token dari header Authorization: Bearer, menghitung hash-nya, mencocokkan hasilnya dengan tabel personal_access_tokens, lalu menetapkan pemilik token sebagai pengguna yang terautentikasi. Apabila teks token tidak cocok dengan baris mana pun, permintaan dijawab dengan status 401.

bash
# menimpa routes/api.php dengan rute terlindungi auth:sanctum
cd ~/aplikasi-catatan
cat > routes/api.php <<'PHP'
<?php

use App\Http\Resources\NoteCollection;
use App\Http\Resources\NoteResource;
use App\Models\Category;
use App\Models\Note;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;

Route::get('/catatan', function () {
    return new NoteCollection(
        Note::with('category')
            ->where('category_id', Category::where('nama', 'Arsip')->value('id'))
            ->orderBy('judul')
            ->get()
    );
});

Route::get('/catatan/{id}', function (string $id) {
    return new NoteResource(
        Note::with('category')->findOrFail($id)
    );
});

Route::post('/catatan', function (Request $request) {
    $data = $request->validate([
        'judul' => ['required', 'string', 'max:100'],
        'isi' => ['required', 'string'],
        'kategori' => ['required', 'string'],
    ]);

    $kategori = Category::firstOrCreate(['nama' => $data['kategori']]);
    Note::firstOrCreate(
        ['judul' => $data['judul']],
        ['isi' => $data['isi'], 'category_id' => $kategori->id]
    );

    return response()->json([
        'pesan' => 'Catatan tersimpan.',
        'judul' => $data['judul'],
    ], 201);
});

Route::middleware(EnsureFrontendRequestsAreStateful::class)->group(function () {
    Route::post('/login', function (Request $request) {
        $data = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required', 'string'],
        ]);

        if (! Auth::attempt($data)) {
            return response()->json(['pesan' => 'Kredensial tidak cocok.'], 401);
        }

        return response()->json([
            'pesan' => 'Login berhasil.',
            'nama' => $request->user()->name,
        ]);
    });

    Route::get('/pengguna', function (Request $request) {
        if (! $request->user()->tokenCan('profil:lihat')) {
            return response()->json([
                'pesan' => 'Token tidak memiliki izin profil:lihat.',
            ], 403);
        }

        return response()->json($request->user()->only(['name', 'email']));
    })->middleware('auth:sanctum');
});
PHP

Tiga rute /catatan dipertahankan dalam bentuk resource, sedangkan dua rute baru menangani autentikasi. Rute GET /api/pengguna mengembalikan nama dan email pengguna yang terautentikasi, dengan satu syarat tambahan berupa pemeriksaan tokenCan('profil:lihat'). Token yang tidak membawa kemampuan tersebut dijawab dengan status 403 meskipun token-nya sah. Grup EnsureFrontendRequestsAreStateful yang membungkus kedua rute baru dibahas pada bagian Mode SPA.

Perilaku empat skenario berikut diuji dengan skrip yang membuat pengguna dan tokennya secara deterministik.

php uji-sanctum-token.php
<?php

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

$root = getcwd();
if (! is_file($root.'/bootstrap/app.php')) {
    $root = getenv('HOME').'/aplikasi-catatan';
}

require $root.'/vendor/autoload.php';

$app = require $root.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();

// pengguna uji dibuat secara deterministik
$pengguna = User::firstOrCreate(
    ['email' => '[email protected]'],
    ['name' => 'Ali', 'password' => 'kunci-rahasia-ali']
);
$pengguna->update(['name' => 'Ali', 'password' => 'kunci-rahasia-ali']);
$pengguna->tokens()->delete();
$tokenA = $pengguna->createToken('uji-berizin', ['profil:lihat']);
$tokenB = $pengguna->createToken('uji-tanpa-izin', ['catatan:lihat']);

$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);

// setiap skenario memulai dari guard yang bersih
Auth::forgetGuards();

// skenario 1: permintaan tanpa token
$resp = $kernel->handle(Request::create('http://localhost/api/pengguna', 'GET', [], [], [], [
    'HTTP_ACCEPT' => 'application/json',
]));
echo '1. tanpa token: status '.$resp->getStatusCode().PHP_EOL;
echo $resp->getContent().PHP_EOL;

// skenario 2: token dengan kemampuan profil:lihat
Auth::forgetGuards();
$resp = $kernel->handle(Request::create('http://localhost/api/pengguna', 'GET', [], [], [], [
    'HTTP_ACCEPT' => 'application/json',
    'HTTP_AUTHORIZATION' => 'Bearer '.$tokenA->plainTextToken,
]));
echo '2. token berizin: status '.$resp->getStatusCode().PHP_EOL;
echo $resp->getContent().PHP_EOL;

// skenario 3: token tanpa kemampuan profil:lihat
Auth::forgetGuards();
$resp = $kernel->handle(Request::create('http://localhost/api/pengguna', 'GET', [], [], [], [
    'HTTP_ACCEPT' => 'application/json',
    'HTTP_AUTHORIZATION' => 'Bearer '.$tokenB->plainTextToken,
]));
echo '3. token tanpa izin: status '.$resp->getStatusCode().PHP_EOL;
echo $resp->getContent().PHP_EOL;

// skenario 4: token dicabut lalu dipakai kembali
Auth::forgetGuards();
$pengguna->tokens()->where('name', 'uji-berizin')->delete();
$resp = $kernel->handle(Request::create('http://localhost/api/pengguna', 'GET', [], [], [], [
    'HTTP_ACCEPT' => 'application/json',
    'HTTP_AUTHORIZATION' => 'Bearer '.$tokenA->plainTextToken,
]));
echo '4. token tercabut: status '.$resp->getStatusCode().PHP_EOL;
echo $resp->getContent().PHP_EOL;
text Keluaran
1. tanpa token: status 401
{"message":"Unauthenticated."}
2. token berizin: status 200
{"name":"Ali","email":"[email protected]"}
3. token tanpa izin: status 403
{"pesan":"Token tidak memiliki izin profil:lihat."}
4. token tercabut: status 401
{"message":"Unauthenticated."}

Skenario pertama dikirim tanpa token dan dijawab dengan status 401 serta pesan Unauthenticated. dari framework. Skenario kedua membawa token dengan kemampuan yang sesuai sehingga dijawab dengan status 200 beserta nama dan email. Skenario ketiga membawa token yang sah tetapi tanpa kemampuan yang dituntut, sehingga dijawab dengan status 403 berupa pesan yang ditetapkan pada rute. Skenario keempat menghapus token terlebih dahulu, dan penggunaan ulang teks token yang sama dijawab dengan status 401 karena tidak ada baris yang cocok pada tabel. Panggilan Auth::forgetGuards() di awal setiap skenario mengosongkan cache guard karena seluruh skenario berjalan pada satu proses PHP yang sama, kondisi yang tidak terjadi pada server nyata. Alur kedua jalur autentikasi digambarkan sebagai berikut.

Jalur token personal accessAuthorization: Bearer {token}hash dicocokkan kepersonal_access_tokenskemampuan diperiksa dengan tokenCan()Jalur SPA (cookie session)GET /sanctum/csrf-cookiePOST /api/login + X-XSRF-TOKEN+ Referer domain statefulpermintaan berikutnya cukup cookie sessionToken dibawa oleh setiap permintaan, sedangkan session dibawa oleh cookie.
Dua jalur autentikasi pada paket Sanctum

Mode SPA#

Jalur kedua ditujukan bagi SPA yang berjalan pada domain yang dikenal aplikasi, dengan autentikasi yang mengandalkan cookie session sehingga token tidak perlu disimpan di browser. Daftar domain yang dianggap stateful ditetapkan pada kunci stateful di config/sanctum.php, dan localhost termasuk di dalamnya secara bawaan. Permintaan yang membawa header Referer atau Origin dari domain tersebut diproses oleh middleware EnsureFrontendRequestsAreStateful, yang menambahkan lapisan cookie dan session ke grup api untuk permintaan itu saja.

Alurnya dimulai dari endpoint bawaan GET /sanctum/csrf-cookie, yang mengirim cookie XSRF-TOKEN beserta cookie session. Permintaan POST berikutnya mengirim kembali cookie tersebut dan menempatkan nilai XSRF-TOKEN pada header X-XSRF-TOKEN agar lulus pemeriksaan CSRF. Rute /api/login kemudian memanggil Auth::attempt(), dan apabila autentikasi berhasil, identitas pengguna disimpan ke session sekaligus id session diregenerasi. Permintaan setelahnya cukup membawa cookie session terbaru, dan auth:sanctum mengenali pengguna dari session tersebut.

php uji-sanctum-spa.php
<?php

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

$root = getcwd();
if (! is_file($root.'/bootstrap/app.php')) {
    $root = getenv('HOME').'/aplikasi-catatan';
}

require $root.'/vendor/autoload.php';

$app = require $root.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();

// pengguna uji dibuat secara deterministik
$pengguna = User::firstOrCreate(
    ['email' => '[email protected]'],
    ['name' => 'Ali', 'password' => 'kunci-rahasia-ali']
);
$pengguna->update(['name' => 'Ali', 'password' => 'kunci-rahasia-ali']);

$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$namaSesi = config('session.cookie');

// langkah 1: minta cookie CSRF dari endpoint bawaan Sanctum
$resp = $kernel->handle(Request::create('http://localhost/sanctum/csrf-cookie', 'GET', [], [], [], [
    'HTTP_ACCEPT' => 'application/json',
]));
$kuki = [];
foreach ($resp->headers->getCookies() as $c) {
    $kuki[$c->getName()] = $c->getValue();
}
ksort($kuki);
echo '1. GET /sanctum/csrf-cookie: status '.$resp->getStatusCode().PHP_EOL;
echo '   cookie diterima: '.implode(', ', array_keys($kuki)).PHP_EOL;

$cookie = 'XSRF-TOKEN='.urlencode($kuki['XSRF-TOKEN']).'; '.$namaSesi.'='.urlencode($kuki[$namaSesi]);
$server = [
    'HTTP_COOKIE' => $cookie,
    'HTTP_X_XSRF_TOKEN' => $kuki['XSRF-TOKEN'],
    'HTTP_REFERER' => 'http://localhost/dashboard',
    'CONTENT_TYPE' => 'application/json',
    'HTTP_ACCEPT' => 'application/json',
];

// langkah 2: login dengan kata sandi salah
Auth::forgetGuards();
$resp = $kernel->handle(Request::create('http://localhost/api/login', 'POST', [], [], [], $server, json_encode([
    'email' => '[email protected]',
    'password' => 'sandisalah',
])));
echo '2. login sandi salah: status '.$resp->getStatusCode().PHP_EOL;
echo $resp->getContent().PHP_EOL;

// langkah 3: login dengan kredensial benar
Auth::forgetGuards();
$resp = $kernel->handle(Request::create('http://localhost/api/login', 'POST', [], [], [], $server, json_encode([
    'email' => '[email protected]',
    'password' => 'kunci-rahasia-ali',
])));
echo '3. login kredensial benar: status '.$resp->getStatusCode().PHP_EOL;
echo $resp->getContent().PHP_EOL;

// session id diregenerasi saat login, cookie terbaru dipakai untuk langkah berikutnya
$kukiBaru = [];
foreach ($resp->headers->getCookies() as $c) {
    $kukiBaru[$c->getName()] = $c->getValue();
}
$sesi = $kukiBaru[$namaSesi] ?? $kuki[$namaSesi];
$cookieSesi = 'XSRF-TOKEN='.urlencode($kuki['XSRF-TOKEN']).'; '.$namaSesi.'='.urlencode($sesi);

// langkah 4: ambil data pengguna memakai cookie session
Auth::forgetGuards();
$resp = $kernel->handle(Request::create('http://localhost/api/pengguna', 'GET', [], [], [], [
    'HTTP_COOKIE' => $cookieSesi,
    'HTTP_REFERER' => 'http://localhost/dashboard',
    'HTTP_ACCEPT' => 'application/json',
]));
echo '4. GET /api/pengguna dengan session: status '.$resp->getStatusCode().PHP_EOL;
echo $resp->getContent().PHP_EOL;

// langkah 5: permintaan sama tanpa header Referer
// skrip berjalan dalam satu proses, sisa session pada proses ini dibersihkan dulu
app('session.store')->flush();
Auth::forgetGuards();
$resp = $kernel->handle(Request::create('http://localhost/api/pengguna', 'GET', [], [], [], [
    'HTTP_COOKIE' => $cookieSesi,
    'HTTP_ACCEPT' => 'application/json',
]));
echo '5. GET tanpa Referer: status '.$resp->getStatusCode().PHP_EOL;
echo $resp->getContent().PHP_EOL;
text Keluaran
1. GET /sanctum/csrf-cookie: status 204
   cookie diterima: XSRF-TOKEN, laravel-session
2. login sandi salah: status 401
{"pesan":"Kredensial tidak cocok."}
3. login kredensial benar: status 200
{"pesan":"Login berhasil.","nama":"Ali"}
4. GET /api/pengguna dengan session: status 200
{"name":"Ali","email":"[email protected]"}
5. GET tanpa Referer: status 401
{"message":"Unauthenticated."}

Langkah pertama mengirim dua cookie yang kelak dikirim ulang oleh browser, yaitu XSRF-TOKEN dan laravel-session. Langkah kedua dengan kata sandi salah dijawab dengan 401 oleh rute login. Langkah ketiga berhasil dan mengembalikan pesan beserta nama pengguna. Langkah keempat membawa cookie session hasil regenerasi, dan permintaan GET /api/pengguna tanpa token pun dijawab dengan 200, membuktikan bahwa autentikasi telah berpindah dari token ke session. Langkah kelima mengirim cookie yang sama tanpa header Referer, sehingga permintaan tidak dianggap berasal dari frontend stateful, lapisan session tidak dijalankan, dan jawabannya adalah 401.

Kesalahan umum#

Kesalahan pertama adalah mengambil teks token kembali dari database. Tabel hanya menyimpan hash, sedangkan plainTextToken hanya ada pada saat createToken() dijalankan. Token yang hilang pada klien tidak dapat dipulihkan, dan perbaikannya adalah mencabut token lama lalu membuat token baru.

Kesalahan kedua adalah memberikan auth:sanctum pada rute SPA tanpa middleware stateful. Lapisan session tidak pernah dijalankan pada grup api yang tidak memuatnya, sehingga login melalui cookie selalu berakhir 401 meskipun kredensialnya benar. Perbaikannya adalah membungkus rute tersebut dengan EnsureFrontendRequestsAreStateful, atau memasangnya untuk seluruh grup api melalui statefulApi().

Kesalahan ketiga adalah mengirim permintaan SPA tanpa header Referer atau Origin. Middleware stateful menilai asal permintaan dari kedua header itu, dan tanpa keduanya permintaan diperlakukan seperti klien yang bukan browser, sehingga cookie session-nya diabaikan. Perbaikannya adalah memastikan frontend mengirim header tersebut dari domain yang terdaftar pada kunci stateful.

Ringkasan#

  • Trait HasApiTokens menyediakan createToken() dan tokens(), dengan teks token berformat {id}|{karakter acak} yang hanya tampil sekali karena tabel menyimpan hash-nya.
  • Middleware auth:sanctum mengautentikasi permintaan dari header Authorization: Bearer, dan tokenCan() membatasi rute berdasarkan kemampuan token dengan jawaban 403 apabila kemampuannya tidak ada.
  • Token yang dicabut langsung tidak sah, sehingga penggunaan ulang teksnya dijawab 401.
  • Mode SPA memanfaatkan cookie session melalui GET /sanctum/csrf-cookie, header X-XSRF-TOKEN, dan middleware stateful yang hanya aktif untuk permintaan dengan Referer dari domain terdaftar.

Referensi#