Chapter sebelumnya sudah memetakan tiga keluarga strategi token dan menyimpulkan bahwa random token adalah titik masuk terbaik untuk memahami autentikasi API. Sekarang kita bongkar mekanismenya dengan membangun sistem bearer token dari nol — tanpa Sanctum, tanpa paket tambahan. Perjalanan kita meliputi tabel penyimpanan token, endpoint penerbitan token saat login, dan middleware yang memverifikasi header Authorization di setiap request. Hasil akhirnya sederhana, tetapi setiap keputusan di sini persis keputusan yang diambil Sanctum di balik layar.

Tabel api_tokens dan Keputusan Penyimpanan #

Token yang disimpan mentah di database adalah bom waktu: begitu database bocor, penyerang langsung memegang identitas semua pengguna. Karena itu sejak migrasi kita simpan hash token, bukan nilainya. Verifikasi token cukup meng-hash balik nilai yang masuk lalu mencocokkannya dengan kolom token — tidak perlu tahu token aslinya.

php
<?php

// database/migrations/2026_10_13_195131_create_api_tokens_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('api_tokens', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->string('token', 64)->unique();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('api_tokens');
    }
};

Kolom token berukuran 64 karakter karena hash('sha256', ...) selalu menghasilkan tepat 64 karakter heksadesimal. Model ApiToken melengkapi tabel ini dengan relasi user(), sementara model User mendapatkan relasi balik apiTokens().

Endpoint Penerbitan Token #

Token diterbitkan saat login: client mengirim email dan password, server memeriksa kredensial, lalu mengembalikan token acak yang hanya ditampilkan sekali. Seluruh alurnya muat dalam satu route:

php
<?php

// routes/api.php (di dalam group prefix v1)

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;

Route::post('/auth/token', function (Request $request) {
    $data = $request->validate([
        'email' => 'required|email',
        'password' => 'required|string',
    ]);

    $user = User::where('email', $data['email'])->first();

    if ($user === null || ! Hash::check($data['password'], $user->password)) {
        throw ValidationException::withMessages([
            'email' => 'Kredensial tidak cocok dengan catatan kami.',
        ]);
    }

    $plaintext = Str::random(60);

    $user->apiTokens()->create([
        'name' => 'token-login',
        'token' => hash('sha256', $plaintext),
    ]);

    return response()->json(['token' => $plaintext], 201);
});

Empat hal terjadi di dalamnya. Pertama, validate() memastikan payload lengkap dan berformat email. Kedua, Hash::check() membandingkan password dengan hash di tabel users — password tidak pernah dibaca mentah. Ketiga, Str::random(60) membuat nilai acak 60 karakter alfanumerik yang menjadi token. Keempat, ke database hanya dikirim hash('sha256', $plaintext), sementara nilai asli dikembalikan ke client sekali ini saja. Kita uji keduanya dari feature test:

Output yang Dihasilkan:

text
### POST /api/v1/auth/token dengan kredensial benar → HTTP 201
{
    "token": "iumpEqt21UenbNQ4FWUyoNI41Woc11r2Xs0580sYiBFL8kXs64MPiVXJ2mdf"
}

### POST /api/v1/auth/token dengan password salah → HTTP 422
{
    "message": "Kredensial tidak cocok dengan catatan kami.",
    "errors": {
        "email": [
            "Kredensial tidak cocok dengan catatan kami."
        ]
    }
}

Middleware Pembaca Bearer Token #

Sisi penerima bekerja dalam empat langkah: baca header Authorization, potong prefiks Bearer , hash nilainya, lalu cari cocokan di api_tokens. Route yang gagal melewati salah satu langkah dijawab 401.

php
<?php

// app/Http/Middleware/EnsureApiTokenIsValid.php

namespace App\Http\Middleware;

use App\Models\ApiToken;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;

class EnsureApiTokenIsValid
{
    public function handle(Request $request, Closure $next): Response
    {
        $header = $request->header('Authorization', '');

        if (! str_starts_with($header, 'Bearer ')) {
            return response()->json(['message' => 'Unauthenticated.'], 401);
        }

        $plaintext = substr($header, strlen('Bearer '));
        $apiToken = ApiToken::query()
            ->where('token', hash('sha256', $plaintext))
            ->first();

        if ($apiToken === null) {
            return response()->json(['message' => 'Unauthenticated.'], 401);
        }

        Auth::setUser($apiToken->user);

        return $next($request);
    }
}

Baris Auth::setUser() menempelkan pemilik token sebagai user aktif untuk request ini — itulah sebabnya $request->user() di controller langsung terisi. Middleware ini didaftarkan sebagai alias api.token lewat withMiddleware() di bootstrap/app.php, lalu dipasang pada endpoint profil:

php
<?php

// routes/api.php (di dalam group prefix v1)

Route::get('/me', function (Request $request) {
    return [
        'id' => $request->user()->id,
        'nama' => $request->user()->name,
        'email' => $request->user()->email,
    ];
})->middleware('api.token');

Kita buktikan ketiga skenario dari feature test — tanpa token, dengan token valid, dan dengan token yang dimodifikasi satu karakter:

Output yang Dihasilkan:

text
### GET /api/v1/me tanpa header Authorization → HTTP 401
{
    "message": "Unauthenticated."
}

### GET /api/v1/me dengan Bearer token valid → HTTP 200
{
    "id": 1,
    "nama": "Budi",
    "email": "[email protected]"
}

### GET /api/v1/me dengan Bearer token ...testing-999 (dimodifikasi) → HTTP 401
{
    "message": "Unauthenticated."
}

Isi Database: Hash, Bukan Token Asli #

Untuk melihat apa yang sebenarnya tersimpan, kita buat token dengan plaintext tetap token-budi-rahasia-untuk-testing-123456 lalu membaca barisnya:

Output yang Dihasilkan:

text
baris di tabel api_tokens:
{
    "id": 1,
    "name": "token-runner",
    "token": "d97fa928366053ec0ffe57ec5e9267dccfd3c539ea48659420038c48ef2d33a7"
}

Kolom token berisi hash('sha256', 'token-budi-rahasia-untuk-testing-123456') — nilai yang deterministik dan bisa dihitung ulang siapa pun, tetapi tidak bisa dibalik menjadi token asli. Query WHERE token = 'token-budi-rahasia-untuk-testing-123456' tidak akan pernah menemukan baris ini.

Kelemahan Sistem Buatan Sendiri #

Sistem kita bekerja, dan itulah pentingnya: mekanismenya sudah tidak lagi sihir. Namun memakainya di produksi berarti menanggung lubang-lubang berikut:

| Aspek | Sistem buatan sendiri | Sanctum | | | | | | Kedaluwarsa token | Tidak ada — token hidup selamanya | Konfigurasi expiration | | Pembatasan hak token | Semua token setara pemiliknya | Abilities + tokenCan() | | Catat pemakaian | Tidak ada | last_used_at otomatis | | Pencabutan massal | Hapus manual per token | Per user atau terjadwal | | Integrasi guard | Auth::setUser() manual | Middleware auth:sanctum siap pakai |

Sanctum pada dasarnya adalah tabel api_tokens versi kita dengan lima fitur tambahan di atas — bentuk JSON 401 "Unauthenticated." di chapter sebelumnya pun persis bentuk yang kita pilih sendiri di sini. Dengan mekanisme yang kini dipahami tuntas, kita bebas memakai Sanctum tanpa rasa bergantung pada sihir, dan pembahasan lebih lanjut tersedia di dokumentasi sanctum . Pertanyaan berikutnya: sudah terautentikasi, tetapi boleh melakukan apa? Jawabannya — gate, policy, dan token abilities — adalah topik chapter API Authorization .