Autentikasi adalah gerbang keamanan paling vital dalam setiap aplikasi web modern. Di era di mana serangan siber semakin canggih, mengamankan data pengguna dan akses API tidak lagi cukup dengan sekadar menyimpan token ke dalam penyimpanan lokal browser. Praktik usang seperti menyimpan JWT mentah di localStorage membuka pintu bagi peretas untuk mencuri sesi pengguna melalui celah Cross-Site Scripting (XSS).
Untuk membangun sistem autentikasi setingkat enterprise, tim rekayasa perangkat lunak perlu memahami arsitektur standar modern seperti OAuth2 dengan Authorization Code + PKCE, JSON Web Tokens (JWT) berumur singkat, HttpOnly Cookies, serta Refresh Token Rotation. Artikel ini mengupas panduan teknis implementasinya, termasuk batas perlindungannya yang sering disalahpahami.
- Menyimpan JWT di localStorage membuat token dapat dicuri oleh skrip apa pun yang berjalan di halaman (risiko XSS). Cookie HttpOnly menyembunyikan token dari JavaScript, tetapi membawa risiko CSRF yang perlu dimitigasi.
- Pola umum: Access Token berumur singkat (misalnya 10-15 menit) disimpan di memori, dan Refresh Token disimpan di cookie HttpOnly, Secure, SameSite yang dibatasi pada path autentikasi.
- HttpOnly bukan perisai XSS: skrip yang disuntikkan masih dapat memakai sesi yang aktif. Kurangi risikonya dengan CSP, sanitasi, dan audit dependensi.
- Refresh Token Rotation dengan deteksi reuse memerlukan jendela toleransi agar penggunaan multi-tab dan retry jaringan tidak memicu logout massal.
- Gunakan Authorization Code flow with PKCE untuk SPA dan mobile. Untuk aplikasi sensitif, pola BFF yang menyimpan token di server lebih disarankan oleh panduan IETF.
- JWT tidak wajib: untuk aplikasi satu domain, session cookie di server sering lebih sederhana dan cukup aman. Payload JWT ditandatangani, bukan dienkripsi.
1. Bahaya Menyimpan Token di LocalStorage
Banyak tutorial pemula mengajarkan penyimpanan token JWT langsung di localStorage.setItem("token", jwt) karena dianggap mudah dan praktis. Namun, di lingkungan produksi, pola ini adalah celah keamanan serius:
Kerentanan XSS (Cross-Site Scripting): Jika website Anda memiliki satu celah XSS (misalnya melalui pustaka pihak ketiga, widget analitik, atau rendering HTML komentar yang belum disanitasi), penyerang dapat menjalankan satu baris JavaScript untuk membaca seluruh token dari
localStoragedan mengirimkannya ke server penyerang. Token yang dicuri dapat dipakai dari perangkat mana pun, bahkan setelah tab ditutup.Trade-off dengan cookie:
localStorageterikat pada origin dan tidak dikirim otomatis ke server. Cookie sebaliknya dikirim otomatis oleh browser sehingga membawa risiko Cross-Site Request Forgery (CSRF). Karena itu cookie autentikasi perlu atributHttpOnly,Secure, danSameSite, serta pemeriksaan tambahan seperti validasi headerOrigin.
Jangan Simpan Token Jangka Panjang di localStorage
2. Pola Autentikasi yang Umum Dipakai: Access Token di Memori + Refresh Token di Cookie
Pola ini membagi siklus hidup token menjadi dua instrumen yang saling melengkapi:
Access Token (umur singkat, misalnya 10-15 menit): Digunakan untuk mengotorisasi setiap panggilan API. Token ini disimpan di memori aplikasi (variabel JavaScript atau state framework), sehingga hilang saat tab ditutup dan tidak tersimpan permanen di browser.
Refresh Token (umur lebih panjang, misalnya 7-30 hari): Digunakan hanya untuk meminta Access Token baru. Token ini disimpan di HttpOnly Cookie, sehingga JavaScript tidak dapat membaca isinya, sementara browser otomatis menyertakannya saat memanggil rute penyegaran token.
Karena Access Token hanya ada di memori, ia hilang saat halaman dimuat ulang. Aplikasi perlu melakukan silent refresh saat start: memanggil endpoint refresh dengan cookie yang ada untuk mendapatkan Access Token baru sebelum merender halaman yang butuh autentikasi.
Token Tidak Terbaca JavaScript
Cookie HttpOnly mencegah skrip membaca refresh token, tetapi skrip yang disuntikkan lewat XSS masih dapat mengirim permintaan atas nama pengguna. Anggap ini lapisan tambahan, bukan pengganti pencegahan XSS.
3. Batas Perlindungan HttpOnly: Yang Masih Bisa Dilakukan XSS
Penting untuk jujur tentang batasnya. Jika penyerang berhasil menjalankan JavaScript di halaman Anda, ia memang tidak dapat membaca isi cookie HttpOnly, tetapi ia masih dapat:
mengirim permintaan API atas nama pengguna lewat sesi yang aktif (browser otomatis menyertakan cookie);
membaca Access Token yang sedang disimpan di memori halaman;
memanggil endpoint refresh untuk mendapatkan Access Token baru selama pengguna masih membuka halaman.
Karena itu pencegahan XSS tetap menjadi lapisan utama. Praktik pendukungnya:
Content Security Policy (CSP) yang ketat untuk membatasi skrip yang boleh berjalan, dan pertimbangkan Trusted Types.
Sanitasi dan encoding output untuk setiap konten yang berasal dari pengguna.
Audit dependensi dan script pihak ketiga secara berkala.
Masa berlaku token yang singkat dan autentikasi ulang untuk aksi sensitif (misalnya ganti password atau transaksi).
4. Tiga Pola Arsitektur Autentikasi untuk Aplikasi Browser
Panduan IETF untuk aplikasi berbasis browser menyajikan beberapa pola dengan tingkat keamanan yang berbeda. Pola yang dibahas di bagian 2 termasuk pola yang menaruh token di sisi browser, yang lebih lemah dibanding BFF untuk aplikasi sensitif. Pilih sesuai profil risiko aplikasi Anda:
| Pola | Posisi token | Kelebihan | Catatan keamanan |
|---|---|---|---|
| Session cookie di server (misalnya Laravel Sanctum mode SPA) | Tidak ada token OAuth di browser, hanya cookie sesi | Sederhana, cocok untuk aplikasi satu domain, JWT tidak diperlukan | Perlu perlindungan CSRF |
| BFF (Backend For Frontend) | Token disimpan di server, browser hanya memegang cookie sesi | Paling ketat: token tidak pernah sampai ke JavaScript | Butuh komponen server tambahan. XSS tetap bisa memakai sesi aktif |
| Access Token di memori + Refresh Token di cookie (pola bagian 2) | Access Token di memori browser, Refresh Token di cookie HttpOnly | Praktis untuk SPA yang memanggil API terpisah | Access Token dapat dibaca oleh skrip yang disuntikkan lewat XSS |
Rekomendasi Singkat
5. OAuth2 dan OpenID Connect: Authorization Code Flow dengan PKCE
OAuth2 adalah kerangka otorisasi (apa yang boleh diakses aplikasi), sedangkan OpenID Connect (OIDC) menambahkan lapisan identitas (siapa penggunanya) di atas OAuth2. Untuk SPA dan aplikasi mobile, praktik terbaik saat ini adalah Authorization Code flow with PKCE. Implicit grant sudah ditinggalkan dalam panduan keamanan terbaru (RFC 9700). Alurnya:
Aplikasi membuat
code_verifieracak dancode_challenge(hash SHA-256 dari verifier).Aplikasi mengarahkan pengguna ke penyedia identitas bersama
code_challenge, redirect URI yang terdaftar, scope, dan parameterstate.Pengguna login (dan MFA bila diaktifkan) di penyedia identitas.
Penyedia identitas mengembalikan authorization code lewat redirect.
Aplikasi menukar code beserta
code_verifierdengan token di token endpoint. Penyerang yang mencegat code tidak dapat menukarnya tanpa verifier.
1function base64Url(bytes: Uint8Array): string {2 let binary = "";3 bytes.forEach((b) => (binary += String.fromCharCode(b)));4 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");5}6 7export async function createPkcePair() {8 // code_verifier: string acak. Simpan sementara dan kirim hanya saat menukar code dengan token.9 const verifier = base64Url(crypto.getRandomValues(new Uint8Array(32)));10 11 // code_challenge: hash SHA-256 dari verifier. Inilah yang dikirim di permintaan otorisasi.12 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));13 const challenge = base64Url(new Uint8Array(digest));14 15 return { verifier, challenge };16}
Untuk produksi, gunakan penyedia identitas yang teruji (misalnya Keycloak, Auth0, atau layanan identitas cloud) beserta library resminya, bukan membangun authorization server sendiri.
6. Implementasi Refresh Token Rotation (RTR) di Backend
Bagaimana jika sebuah Refresh Token dicuri? Di sinilah Refresh Token Rotation bekerja: setiap kali klien meminta penyegaran, backend menerbitkan token baru dan menandai token lama sebagai terpakai. Jika token lama muncul lagi di luar jendela toleransi, itu indikasi dua pihak memegang token yang sama, dan backend mencabut seluruh family token sesi tersebut. RFC 9700 mensyaratkan refresh token untuk public client dilindungi dengan rotasi atau sender-constraining.
Beberapa keputusan desain pada contoh di bawah:
Refresh token berupa string acak (opaque), bukan JWT, dan hanya hash-nya yang disimpan di database.
Family ID menghubungkan seluruh rantai token dari satu login, sehingga pencabutan bisa dilakukan per sesi.
Operasi atomik dengan
SELECT ... FOR UPDATEagar dua permintaan bersamaan diproses berurutan.Jendela toleransi pendek agar penggunaan multi-tab dan retry jaringan tidak memicu deteksi pencurian palsu.
Token tidak dikenal atau kedaluwarsa cukup ditolak. Pencabutan seluruh family hanya untuk kasus reuse.
1CREATE TABLE refresh_tokens (2 id UUID PRIMARY KEY,3 user_id BIGINT NOT NULL,4 family_id UUID NOT NULL,5 token_hash CHAR(64) NOT NULL UNIQUE,6 expires_at TIMESTAMPTZ NOT NULL,7 used_at TIMESTAMPTZ,8 revoked_at TIMESTAMPTZ,9 replaced_by UUID,10 created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP11);12 13CREATE INDEX ON refresh_tokens (family_id);14CREATE INDEX ON refresh_tokens (user_id);
1import { createHash, randomBytes, randomUUID } from "node:crypto";2import jwt from "jsonwebtoken";3import { pool } from "../db";4 5const REFRESH_TTL_DAYS = 7;6const REUSE_GRACE_SECONDS = 10; // toleransi untuk multi-tab dan retry jaringan7 8const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");9 10export type RefreshResult =11 | { ok: true; userId: string; accessToken: string; refreshToken: string }12 | { ok: false; reason: "invalid" | "expired" | "reuse_detected" };13 14export async function rotateRefreshToken(rawToken: string): Promise<RefreshResult> {15 const client = await pool.connect();16 17 try {18 await client.query("BEGIN");19 20 // Kunci baris token agar dua permintaan bersamaan diproses berurutan21 const { rows } = await client.query(22 "SELECT * FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE",23 [sha256(rawToken)],24 );25 const record = rows[0];26 27 if (!record || record.revoked_at) {28 await client.query("ROLLBACK");29 return { ok: false, reason: "invalid" };30 }31 32 if (new Date(record.expires_at) < new Date()) {33 await client.query("ROLLBACK");34 return { ok: false, reason: "expired" };35 }36 37 if (record.used_at) {38 const ageSeconds = (Date.now() - new Date(record.used_at).getTime()) / 1000;39 40 if (ageSeconds > REUSE_GRACE_SECONDS) {41 // Token lama dipakai ulang di luar jendela toleransi: indikasi pencurian.42 await client.query(43 "UPDATE refresh_tokens SET revoked_at = NOW() WHERE family_id = $1 AND revoked_at IS NULL",44 [record.family_id],45 );46 await client.query("COMMIT");47 return { ok: false, reason: "reuse_detected" };48 }49 // Di dalam jendela toleransi: lanjutkan sebagai rotasi biasa (multi-tab atau retry)50 }51 52 // Tandai token ini terpakai dan terbitkan penggantinya di family yang sama53 const newRawToken = randomBytes(32).toString("base64url");54 const newId = randomUUID();55 56 await client.query(57 "UPDATE refresh_tokens SET used_at = COALESCE(used_at, NOW()), replaced_by = $2 WHERE id = $1",58 [record.id, newId],59 );60 await client.query(61 `INSERT INTO refresh_tokens (id, user_id, family_id, token_hash, expires_at)62 VALUES ($1, $2, $3, $4, NOW() + make_interval(days => $5))`,63 [newId, record.user_id, record.family_id, sha256(newRawToken), REFRESH_TTL_DAYS],64 );65 await client.query("COMMIT");66 67 const accessToken = jwt.sign(68 { sub: String(record.user_id) },69 process.env.ACCESS_SECRET!,70 {71 algorithm: "HS256",72 expiresIn: "15m",73 issuer: "arkatek-auth",74 audience: "arkatek-api",75 },76 );77 78 return {79 ok: true,80 userId: String(record.user_id),81 accessToken,82 refreshToken: newRawToken,83 };84 } catch (error) {85 await client.query("ROLLBACK").catch(() => undefined);86 throw error;87 } finally {88 client.release();89 }90}
Jendela Toleransi Adalah Trade-off
Berikut controller yang memakainya. Controller ini memerlukan middleware cookie-parser, membatasi cookie hanya pada path autentikasi, dan membedakan penyebab kegagalan tanpa membocorkan detail berlebihan ke klien:
1import type { Request, Response } from "express";2import { rotateRefreshToken } from "../services/refreshTokenService";3 4const REFRESH_COOKIE = "refreshToken";5 6export async function refreshAccessToken(req: Request, res: Response) {7 // Membutuhkan middleware cookie-parser8 const oldToken = req.cookies[REFRESH_COOKIE];9 if (!oldToken) {10 return res.status(401).json({ error: "Sesi tidak ditemukan, silakan login." });11 }12 13 const result = await rotateRefreshToken(oldToken);14 15 if (!result.ok) {16 res.clearCookie(REFRESH_COOKIE, { path: "/api/auth" });17 const message =18 result.reason === "reuse_detected"19 ? "Aktivitas mencurigakan terdeteksi. Silakan login ulang."20 : "Sesi tidak valid atau kedaluwarsa. Silakan login ulang.";21 return res.status(401).json({ error: message });22 }23 24 res.cookie(REFRESH_COOKIE, result.refreshToken, {25 httpOnly: true,26 secure: process.env.NODE_ENV === "production", // hanya HTTPS di produksi27 sameSite: "strict",28 path: "/api/auth", // cookie hanya dikirim ke rute autentikasi29 maxAge: 7 * 24 * 60 * 60 * 1000, // 7 hari30 });31 32 // Access Token dikembalikan di body untuk disimpan di memori klien33 return res.status(200).json({ accessToken: result.accessToken });34}
Tambahkan juga pemeriksaan header Origin pada endpoint refresh dan logout sebagai lapisan CSRF tambahan, dan pastikan logout mencabut seluruh family token sesi yang bersangkutan lalu menghapus cookie.
7. Memverifikasi Access Token dan Kesalahan Umum JWT
Verifikasi yang ceroboh adalah sumber celah JWT yang klasik. Selalu batasi algoritma yang diterima dan validasi klaim, sesuai RFC 8725:
1import type { NextFunction, Request, Response } from "express";2import jwt from "jsonwebtoken";3 4export function requireAuth(req: Request, res: Response, next: NextFunction) {5 const header = req.headers.authorization;6 const token = header?.startsWith("Bearer ") ? header.slice(7) : null;7 if (!token) {8 return res.status(401).json({ error: "Token tidak ditemukan." });9 }10 11 try {12 const payload = jwt.verify(token, process.env.ACCESS_SECRET!, {13 algorithms: ["HS256"], // jangan biarkan token menentukan algoritmanya sendiri14 issuer: "arkatek-auth",15 audience: "arkatek-api",16 }) as jwt.JwtPayload;17 18 // req.user memerlukan deklarasi tipe tambahan pada proyek TypeScript Anda19 (req as Request & { user?: { id: string } }).user = { id: String(payload.sub) };20 return next();21 } catch {22 return res.status(401).json({ error: "Token tidak valid atau kedaluwarsa." });23 }24}
Payload tidak terenkripsi: JWT hanya ditandatangani dan di-encode. Siapa pun yang memegang token dapat membaca isinya, jadi jangan menaruh data pribadi sensitif atau rahasia di payload.
Validasi klaim: periksa
exp,iss, danaud, dan tolak token yang tidak memenuhinya.Hindari JWT berumur panjang: JWT tidak dapat dicabut seketika. Andalkan umur pendek dan pencabutan di level refresh token, atau gunakan denylist untuk kasus khusus.
Kelola kunci: jika banyak layanan memverifikasi token, pertimbangkan algoritma asimetris dengan rotasi kunci (
kiddan JWKS) agar tidak membagikan secret yang sama ke semua layanan.Refresh token tidak harus JWT: token opaque yang disimpan hash-nya di database lebih mudah dicabut.
Evaluasi: Stateful Session vs Stateless JWT
- Skalabilitas horizontal: verifikasi dapat dilakukan tanpa lookup basis data pada setiap request
- Cocok untuk API yang dipakai banyak layanan atau klien terpisah
- Membawa identitas dasar pengguna (claim) yang dapat diverifikasi tanpa lookup, meski isinya tidak terenkripsi
- Pencabutan instan sebelum kedaluwarsa memerlukan denylist atau pencabutan di tingkat refresh token
- Payload hanya ditandatangani: jangan menyimpan data sensitif di dalamnya
- Aplikasi satu domain yang sederhana sering lebih mudah diamankan dengan session cookie di server
- Ukuran header HTTP lebih besar dibanding session ID tradisional
8. Checklist Keamanan Tambahan
HTTPS di mana-mana dan aktifkan HSTS.
Hashing password dengan algoritma modern (Argon2id atau bcrypt), dan jangan pernah menyimpan password mentah.
Rate limiting dan proteksi brute force pada endpoint login dan refresh.
MFA atau passkey untuk akun bernilai tinggi.
Content Security Policy yang ketat untuk menekan dampak XSS.
Log audit untuk login, refresh, reuse terdeteksi, dan logout.
Integrasikan dengan Arsitektur Backend Anda
Sistem autentikasi yang solid merupakan lapisan pertahanan utama dari arsitektur backend aplikasi web Anda. Terapkan pola ini berdampingan dengan teknik optimasi query dan service pattern yang diulas di Tutorial RESTful API Skalabel Laravel & PostgreSQL serta ikuti prinsip isolasi modul dalam Panduan Arsitektur Web Modern: Monolith vs Microservices. Bila Anda mengekspos fitur AI, lindungi endpoint-nya dengan autentikasi dan kuota seperti dibahas di Tutorial Integrasi LLM API ke Backend. Untuk autentikasi mesin-ke-mesin seperti webhook, pakai verifikasi signature seperti di Otomatisasi Alur Kerja dengan Webhooks dan AI Agents.
Ingin Mengamankan Sistem dan Aplikasi Web Perusahaan Anda?
Diskusikan audit keamanan aplikasi, implementasi arsitektur SSO/OAuth2, dan pengembangan software kustom bersama engineer Arkatek.
Pertanyaan Umum Seputar Autentikasi OAuth2 dan JWT
Apakah aman menyimpan JWT di localStorage?
Tidak disarankan. Setiap skrip yang berjalan di halaman, termasuk yang masuk lewat celah XSS atau pustaka pihak ketiga, dapat membaca isi localStorage dan mencuri token.
Apakah cookie HttpOnly membuat aplikasi kebal XSS?
Tidak. HttpOnly mencegah skrip membaca cookie, tetapi skrip yang disuntikkan masih dapat mengirim permintaan atas nama pengguna lewat sesi yang aktif. Pencegahan XSS (CSP, sanitasi, audit dependensi) tetap diperlukan.
Kapan memakai session cookie biasa dibanding JWT?
Untuk aplikasi satu domain dengan backend sendiri, session cookie di server sering lebih sederhana dan mudah dicabut. JWT lebih relevan bila token harus dipakai banyak layanan atau klien terpisah.
Apa itu PKCE dan mengapa penting untuk SPA?
PKCE menambahkan pasangan code_verifier dan code_challenge pada Authorization Code flow, sehingga penyerang yang mencegat authorization code tidak dapat menukarnya dengan token. Ini praktik yang direkomendasikan untuk SPA dan aplikasi mobile.
Bagaimana rotasi refresh token menangani banyak tab?
Dengan jendela toleransi singkat: token yang baru dipakai masih diterima beberapa detik sehingga dua tab atau retry jaringan tidak memicu deteksi pencurian palsu. Di luar jendela itu, pemakaian ulang dianggap reuse dan seluruh family dicabut.
Kesimpulan
Keamanan autentikasi web tidak boleh dikorbankan demi kemudahan implementasi sesaat. Dengan meninggalkan kebiasaan menyimpan token di localStorage, memakai Authorization Code dengan PKCE, dan menerapkan Access Token berumur pendek serta Refresh Token berotasi di cookie HttpOnly, aplikasi Anda memiliki pertahanan yang jauh lebih kuat. Namun ingat bahwa tidak ada pola yang kebal terhadap XSS, jadi lengkapi dengan CSP, sanitasi, audit dependensi, dan, untuk aplikasi sensitif, pertimbangkan pola BFF.
Referensi: OAuth 2.0 for Browser-Based Applications (IETF), RFC 9700: Best Current Practice for OAuth 2.0 Security, dan OWASP OAuth 2.0 Cheat Sheet.