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.
| Aspek | API Key Statis | Personal Access Token |
|---|---|---|
| Pemilik | Satu klien atau layanan | Terikat ke user tertentu |
| Penyimpanan server | Satu hash per klien | Hash per token, bisa banyak per user |
| Pembatasan hak | Semua atau tidak sama sekali | Per-token scope dan masa berlaku |
| Pencabutan | Ganti kunci, semua klien ikut ganti | Cukup hapus/baris token yang bermasalah |
| Cocok untuk | Server ke server yang saling percaya | Aplikasi 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
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:
string(12) "abc123def456"
string(12) "abc123def456"
NULL
NULL
NULLPola 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
// 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:
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
$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:
Token dibuat; panjang 27 karakter (nilai mentah tidak ditampilkan)
Pemilik token: Budi, scope: posts:read posts:write
NULL
NULLMari kita bedah bagian-bagian kuncinya:
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.resolveUserByToken()mencari baris token lewat hash, lalu melewati dua gerbang:revoked_atharusNULL(belum dicabut) danexpires_atharus masih di masa depan relatif terhadap$now.- 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. - Pencabutan hanyalah mengisi kolom
revoked_atdengan 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
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:
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
// 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:
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.