Web Development

Implementasi Autentikasi Modern (OAuth2 & JWT) pada Aplikasi Web: Panduan Praktis dan Aman

R

Raka Haikal

Tim Teknis Arkatek

·
8 min baca
·
9x dibaca
Ilustrasi Implementasi Autentikasi Modern (OAuth2 & JWT) pada Aplikasi Web: Panduan Praktis dan Aman

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.

Poin Penting / Key Takeaways
  • 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 localStorage dan mengirimkannya ke server penyerang. Token yang dicuri dapat dipakai dari perangkat mana pun, bahkan setelah tab ditutup.

  • Trade-off dengan cookie: localStorage terikat 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 atribut HttpOnly, Secure, dan SameSite, serta pemeriksaan tambahan seperti validasi header Origin.

Pola ini membagi siklus hidup token menjadi dua instrumen yang saling melengkapi:

  1. 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.

  2. 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.

BUKAN KEBAL XSS

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.

HttpOnly

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:

PolaPosisi tokenKelebihanCatatan keamanan
Session cookie di server (misalnya Laravel Sanctum mode SPA)Tidak ada token OAuth di browser, hanya cookie sesiSederhana, cocok untuk aplikasi satu domain, JWT tidak diperlukanPerlu perlindungan CSRF
BFF (Backend For Frontend)Token disimpan di server, browser hanya memegang cookie sesiPaling ketat: token tidak pernah sampai ke JavaScriptButuh 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 HttpOnlyPraktis untuk SPA yang memanggil API terpisahAccess Token dapat dibaca oleh skrip yang disuntikkan lewat XSS
Rekomendasi Singkat
Untuk aplikasi bisnis atau data sensitif, pertimbangkan BFF. Untuk aplikasi satu domain yang sederhana, session cookie di server biasanya sudah cukup dan lebih mudah diamankan. Pola Access Token di memori lebih cocok bila SPA harus memanggil API terpisah, dengan CSP dan token berumur pendek sebagai pengaman.

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:

  1. Aplikasi membuat code_verifier acak dan code_challenge (hash SHA-256 dari verifier).

  2. Aplikasi mengarahkan pengguna ke penyedia identitas bersama code_challenge, redirect URI yang terdaftar, scope, dan parameter state.

  3. Pengguna login (dan MFA bila diaktifkan) di penyedia identitas.

  4. Penyedia identitas mengembalikan authorization code lewat redirect.

  5. Aplikasi menukar code beserta code_verifier dengan token di token endpoint. Penyerang yang mencegat code tidak dapat menukarnya tanpa verifier.

src/auth/pkce.ts
TS
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}
Membuat pasangan code_verifier dan code_challenge (S256) di browser.

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 UPDATE agar 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.

database/migrations/create_refresh_tokens.sql
SQL
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_TIMESTAMP
11);
12
13CREATE INDEX ON refresh_tokens (family_id);
14CREATE INDEX ON refresh_tokens (user_id);
Tabel refresh token: hanya hash yang disimpan, dengan family ID untuk pencabutan per sesi.
src/services/refreshTokenService.ts
TS
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 jaringan
7
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 berurutan
21 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 sama
53 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}
Rotasi refresh token atomik dengan deteksi reuse dan jendela toleransi.

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:

src/controllers/authController.ts
TS
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-parser
8 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 produksi
27 sameSite: "strict",
28 path: "/api/auth", // cookie hanya dikirim ke rute autentikasi
29 maxAge: 7 * 24 * 60 * 60 * 1000, // 7 hari
30 });
31
32 // Access Token dikembalikan di body untuk disimpan di memori klien
33 return res.status(200).json({ accessToken: result.accessToken });
34}
Controller penyegaran token dengan cookie HttpOnly yang dibatasi path.

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:

src/middleware/requireAuth.ts
TS
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 sendiri
14 issuer: "arkatek-auth",
15 audience: "arkatek-api",
16 }) as jwt.JwtPayload;
17
18 // req.user memerlukan deklarasi tipe tambahan pada proyek TypeScript Anda
19 (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}
Verifikasi JWT dengan algoritma, issuer, dan audience yang dibatasi.
  • 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, dan aud, 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 (kid dan 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

Keunggulan 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
Pertimbangan
  • 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.

SOLUSI TEKNOLOGI ARKATEK

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.

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.

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.

R

// DITULIS OLEH

Raka Haikal

Pengembang perangkat lunak & spesialis arsitektur sistem digital di Arkatek. Berfokus pada pembangunan solusi software scalable, otomatisasi sistem bisnis, dan optimasi performa.

// EKSPLORASI TOPIK

Artikel Terkait Lainnya

Lihat Semua Artikel →