Session berbasis cookie, yang kita bangun pada chapter sebelumnya, dirancang untuk browser: cookie otomatis dikirim dan server menyimpan state login. Klien seperti aplikasi mobile, skrip CLI, atau server milik tim lain tidak bisa bergantung pada cookie dan session — mereka butuh cara mengirim bukti identitas di setiap permintaan secara mandiri. Jawabannya adalah token API yang dikirim lewat header Authorization. Pada chapter ini kita membangun skema personal access token: token acak yang hanya disimpan sebagai hash, terikat ke user, dibatasi scope, berjangka waktu, dan bisa dicabut.

Dua Pendekatan Token API #

Ada dua keluarga token API yang sering dijumpai. API key statis adalah satu string panjang yang dibuat sekali dan dipakai selamanya; server menyimpannya (idealnya juga sebagai hash) dan membandingkannya saat permintaan datang. Personal access token (PAT) dibuat oleh user melalui aplikasi — seperti “Generate token” di GitHub — sehingga setiap token tahu pemiliknya, bisa diberi nama, scope, dan dicabut satu per satu tanpa menyentuh token lain.

AspekAPI Key StatisPersonal Access Token
PemilikSatu klien atau layananTerikat ke user tertentu
Penyimpanan serverSatu hash per klienHash per token, bisa banyak per user
Pembatasan hakSemua atau tidak sama sekaliPer-token scope dan masa berlaku
PencabutanGanti kunci, semua klien ikut gantiCukup hapus/baris token yang bermasalah
Cocok untukServer ke server yang saling percayaAplikasi pihak ketiga yang bertindak atas nama user

Keduanya tidak saling menggantikan: kunci statis sederhana untuk integrasi tetap sah untuk dua server tepercaya, sedangkan PAT unggul begitu token dibagikan ke pihak yang kurang dipercaya. Laravel mengemas persis pola PAT ini dalam paket Sanctum — membaca kode sumbernya setelah chapter ini akan terasa familier.

Membaca Header Authorization #

Standar de facto pengiriman token adalah header Authorization dengan skema Bearer, misalnya Authorization: Bearer <token>. Nilai header tidak boleh dipercaya mentah-mentah: header bisa hilang, salah format, atau memakai skema lain. Fungsi parsing yang menoleransi semua kondisi tersebut:

php
<?php
function parseBearerToken(?string $header): ?string
{
    if ($header === null) {
        return null;
    }
    if (preg_match('/^Bearer\s+(\S+)$/i', $header, $matches) !== 1) {
        return null;
    }

    return $matches[1];
}

var_dump(parseBearerToken("Bearer abc123def456"));
var_dump(parseBearerToken("bearer abc123def456"));
var_dump(parseBearerToken("Token abc123def456"));
var_dump(parseBearerToken(""));
var_dump(parseBearerToken(null));

Output yang Dihasilkan:

text
string(12) "abc123def456"
string(12) "abc123def456"
NULL
NULL
NULL

Pola regex ^Bearer\s+(\S+)$ menuntut nilai diawali kata “Bearer” (huruf besar-kecil diabaikan lewat modifier i), diikuti spasi, lalu satu kelompok karakter non-spasi sampai akhir string. Header tanpa skema Bearer, string kosong, dan null semuanya mengembalikan null — pemanggil tinggal memeriksa satu nilai untuk menyimpulkan “tidak ada token yang sah”. Di aplikasi web nyata, nilai header dibaca dari $_SERVER["HTTP_AUTHORIZATION"] ?? null, dengan null sebagai nilai bawaan saat header tidak dikirim klien.

Menyimpan Hash Token, Bukan Token Mentah #

Prinsip yang sama dengan password berlaku untuk token: basis data yang bocor tidak boleh cukup untuk meniru klien. Bedanya, token kita hasil random_bytes(32) — 256 bit entropi yang mustahil ditebak — sehingga SHA-256 cepat sudah memadai dan tidak perlu algoritma lambat seperti bcrypt yang kita pakai untuk password lemah buatan manusia:

php
<?php
// Nilai token demo memakai string tetap agar output contoh ini deterministik.
$tokenDemo = "demo-token-budi-2026";

$hashTersimpan = hash("sha256", $tokenDemo);
echo "Hash yang disimpan di database: $hashTersimpan\n";

// Saat permintaan masuk, token dari header di-hash dengan cara yang sama.
$hashDariHeader = hash("sha256", "demo-token-budi-2026");
var_dump($hashDariHeader === $hashTersimpan);

// Satu karakter berbeda menghasilkan hash yang sepenuhnya berbeda.
var_dump(hash("sha256", "demo-token-budi-2027") === $hashTersimpan);

Output yang Dihasilkan:

text
Hash yang disimpan di database: 0696d696bc461b4b31f50e0b639cb7493d060ba4320745386a0ad8b96ccbe25f
bool(true)
bool(false)

Alur kerjanya: saat membuat token, server menghitung hash("sha256", $token) dan menyimpan hasilnya; token mentah ditampilkan sekali kepada user lalu dilupakan. Saat permintaan datang, token dari header di-hash ulang dengan cara yang sama, dan dua hash yang identik membuktikan token yang sama tanpa server pernah menyimpan token mentahnya. Karena SHA-256 deterministik, hash dari string contoh di atas selalu sama — pada contoh nyata token berasal dari random_bytes() dan tidak pernah dicetak.

Mencari Token dan Resolusi User #

Penyimpanan hash membuat pencarian token menjadi lookup biasa: hash token yang datang dicari di kolom token_hash yang berindex, lalu user_id pada baris tersebut memberi tahu pemiliknya. Skrip berikut merangkai siklus hidup lengkap sebuah PAT — pembuatan, resolusi user, kedaluwarsa, dan pencabutan:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT NOT NULL UNIQUE,
    nama TEXT NOT NULL
)");
$pdo->exec("CREATE TABLE personal_access_tokens (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL,
    nama TEXT NOT NULL,
    token_hash TEXT NOT NULL UNIQUE,
    scopes TEXT NOT NULL,
    expires_at TEXT,
    revoked_at TEXT
)");

function createToken(PDO $pdo, int $userId, string $nama, string $scopes, ?string $expiresAt): string
{
    // Produksi: $token = bin2hex(random_bytes(32));
    // Demo memakai nilai tetap agar output contoh ini deterministik.
    $token = "demo-token-tetap-untuk-buku";

    $stmt = $pdo->prepare(
        "INSERT INTO personal_access_tokens (user_id, nama, token_hash, scopes, expires_at)
         VALUES (?, ?, ?, ?, ?)"
    );
    $stmt->execute([$userId, $nama, hash("sha256", $token), $scopes, $expiresAt]);

    return $token;
}

function resolveUserByToken(PDO $pdo, string $token, string $now): ?array
{
    $stmt = $pdo->prepare(
        "SELECT u.nama, t.scopes, t.expires_at, t.revoked_at
         FROM personal_access_tokens t
         JOIN users u ON u.id = t.user_id
         WHERE t.token_hash = ?"
    );
    $stmt->execute([hash("sha256", $token)]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    if ($row === false || $row["revoked_at"] !== null) {
        return null;
    }
    if ($row["expires_at"] !== null && $row["expires_at"] <= $now) {
        return null;
    }

    return $row;
}

$pdo->exec("INSERT INTO users (email, nama) VALUES ('[email protected]', 'Budi')");

$token = createToken($pdo, 1, "skrip-cli-budi", "posts:read posts:write", "2026-06-01 00:00:00");
echo "Token dibuat; panjang " . strlen($token) . " karakter (nilai mentah tidak ditampilkan)\n";

$user = resolveUserByToken($pdo, $token, "2026-01-15 08:00:00");
echo "Pemilik token: {$user['nama']}, scope: {$user['scopes']}\n";

// Token yang sama setelah tanggal kedaluwarsa tidak lagi sah.
var_dump(resolveUserByToken($pdo, $token, "2026-06-01 00:00:01"));

// Token dicabut manual sebelum kedaluwarsa juga tidak sah.
$pdo->prepare("UPDATE personal_access_tokens SET revoked_at = ? WHERE token_hash = ?")
    ->execute(["2026-02-01 10:00:00", hash("sha256", $token)]);
var_dump(resolveUserByToken($pdo, $token, "2026-02-01 10:00:02"));

Output yang Dihasilkan:

text
Token dibuat; panjang 27 karakter (nilai mentah tidak ditampilkan)
Pemilik token: Budi, scope: posts:read posts:write
NULL
NULL

Mari kita bedah bagian-bagian kuncinya:

  1. createToken() menyimpan hash SHA-256 token beserta metadata: nama yang manusiawi (skrip-cli-budi), daftar scope, dan masa berlaku. Token mentah dikembalikan ke pemanggil untuk ditampilkan sekali.
  2. resolveUserByToken() mencari baris token lewat hash, lalu melewati dua gerbang: revoked_at harus NULL (belum dicabut) dan expires_at harus masih di masa depan relatif terhadap $now.
  3. Kedaluwarsa ditangani baris expires_at <= $now — token yang sama sah pada 15 Januari pukul 08:00 namun ditolak pada 1 Juni pukul 00:00:01, tanpa perlu menghapus barisnya.
  4. Pencabutan hanyalah mengisi kolom revoked_at dengan stempel waktu; setelah itu token mati seketika meskipun belum kedaluwarsa. Riwayatnya tetap ada untuk audit, dan penghapusan fisik bisa dilakukan lewat tugas pembersihan berkala.

Scope Token #

Token yang mewakili user tidak harus mewakili seluruh kemampuannya. Kolom scopes menyimpan daftar kemampuan yang diizinkan — dipisah spasi, misalnya posts:read posts:write — sehingga token untuk skrip backup cukup membaca data tanpa hak menulis. Pemeriksaannya bekerja pada string scope, terlepas dari database:

php
<?php
function tokenCan(string $scopes, string $neededScope): bool
{
    $daftar = array_filter(explode(" ", $scopes));

    return in_array($neededScope, $daftar, true) || in_array("*", $daftar, true);
}

var_dump(tokenCan("posts:read posts:write", "posts:read"));
var_dump(tokenCan("posts:read posts:write", "posts:delete"));
var_dump(tokenCan("*", "posts:delete"));

Output yang Dihasilkan:

text
bool(true)
bool(false)
bool(true)

Fungsi tokenCan() memecah string scope menjadi array, lalu memeriksa keanggotaan scope yang diminta. Entri * berlaku sebagai wildcard yang meloloskan semua scope — berguna untuk token pribadi milik sendiri, tetapi sebaiknya tidak pernah diberikan kepada pihak ketiga. Gabungkan pemeriksaan ini dengan resolusi user: middleware API memanggil resolveUserByToken() untuk tahu siapa pemiliknya, lalu tokenCan() untuk tahu boleh melakukan apa dalam konteks request tersebut.

Perbandingan Aman terhadap Timing Attack #

Untuk kunci statis yang dibandingkan langsung, === membocorkan informasi lewat waktu eksekusi: perbandingan string berhenti di byte pertama yang berbeda, sehingga penyerang bisa mengukur respons server untuk menebak kunci byte demi byte. Fungsi hash_equals() membandingkan dua string dalam waktu konstan, apa pun isinya:

php
<?php
// Kunci API statis milik sebuah klien (nilai contoh, bukan kunci nyata).
$kunciTersimpan = hash("sha256", "YOUR_API_KEY");

$sah = hash_equals($kunciTersimpan, hash("sha256", "YOUR_API_KEY"));
$palsu = hash_equals($kunciTersimpan, hash("sha256", "kunci-tebakan-penyerang"));

var_dump($sah);
var_dump($palsu);

Output yang Dihasilkan:

text
bool(true)
bool(false)

Pada skema PAT kita, pencarian lewat index token_hash sudah tidak praktis untuk diserang timing (nilai yang ditebak adalah hash dari token 256-bit), sehingga hash_equals() bukan keharusan di jalur itu. Untuk kunci statis yang nilainya dipilih manusia atau berentropi lebih rendah, hash_equals() adalah kewajiban — pola yang sama dengan yang akan kita lihat pada parameter state di chapter OAuth2.

Referensi #

Dokumentasi resmi untuk pendalaman lebih lanjut:

Ringkasan #

Personal access token memberi klien non-browser cara mengautentikasi tanpa session: token acak dikirim lewat header Authorization: Bearer, disimpan di server hanya sebagai hash SHA-256, dan diresolusi menjadi user pemiliknya lewat satu query berindex. Scope membatasi jangkauan tiap token, kedaluwarsa membatasi umurnya, dan kolom revoked_at memberi tombol pembunuh yang bekerja seketika. Model ini mengasumsikan kita yang menerbitkan token untuk user kita sendiri. Bagaimana jika justru penyedia lain — Google, GitHub — yang harus mengautentikani user, dan aplikasi kita hanya meminjam kepercayaan itu? Itu wilayah OAuth2 dan OpenID Connect pada chapter berikutnya.