JWT, Access Token dan Refresh Token

A
IQBAL FARHAN SYUHADA
24 Jun 2026

https://claude.ai/share/1f3977cf-6b45-46a6-9207-7c950c80bf13

Oke, gw bahas JWT, access token, dan refresh token secara lengkap — dari konsep sampai implementasi di React.


Konsep Dasar

JWT (JSON Web Token) adalah format token berbasis JSON yang digunakan untuk transmisi data secara aman antar pihak. Strukturnya terdiri dari 3 bagian: header.payload.signature.

Access Token adalah token berumur pendek (biasanya 15 menit) yang digunakan untuk mengakses resource yang dilindungi. Refresh Token adalah token berumur panjang (7-30 hari) yang digunakan untuk mendapatkan access token baru tanpa login ulang.

Mari lihat alurnya dulu secara visual:Sekarang langsung ke implementasi. Ini adalah setup yang production-ready dengan pattern terbaik:


Implementasi React

1. Setup Axios dengan Interceptor

Ini adalah inti dari implementasi — interceptor yang otomatis handle token refresh:

// src/lib/axios.js
import axios from 'axios';

const api = axios.create({
  baseURL: process.env.REACT_APP_API_URL,
  withCredentials: true, // penting! untuk kirim httpOnly cookie (refresh token)
});

// ✅ Request interceptor — inject access token ke setiap request
api.interceptors.request.use((config) => {
  const token = getAccessToken(); // dari memory, bukan localStorage
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// ✅ Response interceptor — handle 401 & auto-refresh
let isRefreshing = false;
let failedQueue = [];

const processQueue = (error, token = null) => {
  failedQueue.forEach((prom) => {
    if (error) prom.reject(error);
    else prom.resolve(token);
  });
  failedQueue = [];
};

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // Antri request lain yang gagal selama proses refresh
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        })
          .then((token) => {
            originalRequest.headers.Authorization = `Bearer ${token}`;
            return api(originalRequest);
          })
          .catch((err) => Promise.reject(err));
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        const { data } = await axios.post(
          `${process.env.REACT_APP_API_URL}/auth/refresh`,
          {},
          { withCredentials: true } // kirim httpOnly cookie
        );

        const newToken = data.accessToken;
        setAccessToken(newToken); // simpan ke memory
        processQueue(null, newToken);
        originalRequest.headers.Authorization = `Bearer ${newToken}`;
        return api(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError, null);
        clearAccessToken();
        window.location.href = '/login'; // atau dispatch logout action
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

export default api;

2. Token Storage — Simpan di Memory, Bukan localStorage

// src/lib/tokenStorage.js
// ❌ JANGAN simpan di localStorage — rentan XSS attack
// ✅ Simpan access token di memory (module-level variable)

let accessToken = null;

export const getAccessToken = () => accessToken;

export const setAccessToken = (token) => {
  accessToken = token;
};

export const clearAccessToken = () => {
  accessToken = null;
};

Kenapa? localStorage bisa dibaca oleh JavaScript mana saja — termasuk script XSS. Memory variable hanya hidup selama session dan tidak bisa diakses dari luar module. Refresh token disimpan di httpOnly cookie oleh server — tidak bisa diakses JS sama sekali.

3. Auth Context

// src/contexts/AuthContext.jsx
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import api from '../lib/axios';
import { setAccessToken, clearAccessToken } from '../lib/tokenStorage';

const AuthContext = createContext(null);

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true); // untuk silent refresh saat app load

  // Silent refresh saat app pertama kali dibuka
  // Jika user punya refresh token yang valid di cookie, auto-login
  useEffect(() => {
    const silentRefresh = async () => {
      try {
        const { data } = await api.post('/auth/refresh');
        setAccessToken(data.accessToken);
        setUser(data.user);
      } catch {
        // Refresh token expired/invalid — user perlu login ulang
        clearAccessToken();
      } finally {
        setIsLoading(false);
      }
    };

    silentRefresh();
  }, []);

  const login = useCallback(async (email, password) => {
    const { data } = await api.post('/auth/login', { email, password });
    setAccessToken(data.accessToken);
    setUser(data.user);
    // refreshToken otomatis disimpan server sebagai httpOnly cookie
    return data.user;
  }, []);

  const logout = useCallback(async () => {
    try {
      await api.post('/auth/logout'); // server hapus refresh token dari DB & cookie
    } finally {
      clearAccessToken();
      setUser(null);
    }
  }, []);

  return (
    <AuthContext.Provider value={{ user, isLoading, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used within AuthProvider');
  return ctx;
};

4. Protected Route

// src/components/ProtectedRoute.jsx
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';

export const ProtectedRoute = () => {
  const { user, isLoading } = useAuth();

  if (isLoading) return <div>Loading...</div>;

  return user ? <Outlet /> : <Navigate to="/login" replace />;
};

5. Contoh Penggunaan di Component

// src/pages/Dashboard.jsx
import { useEffect, useState } from 'react';
import api from '../lib/axios';
import { useAuth } from '../contexts/AuthContext';

export const Dashboard = () => {
  const { user, logout } = useAuth();
  const [data, setData] = useState(null);

  useEffect(() => {
    // Tidak perlu manually set header — axios interceptor handle otomatis
    api.get('/api/dashboard')
      .then(res => setData(res.data))
      .catch(console.error);
  }, []);

  return (
    <div>
      <h1>Hello, {user?.name}</h1>
      <button onClick={logout}>Logout</button>
      {data && <pre>{JSON.stringify(data, null, 2)}</pre>}
    </div>
  );
};

6. App.jsx — Wiring Semua Komponen

// src/App.jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import { ProtectedRoute } from './components/ProtectedRoute';
import { Login } from './pages/Login';
import { Dashboard } from './pages/Dashboard';

function App() {
  return (
    <BrowserRouter>
      <AuthProvider>
        <Routes>
          <Route path="/login" element={<Login />} />
          <Route element={<ProtectedRoute />}>
            <Route path="/dashboard" element={<Dashboard />} />
            {/* tambah route protected lainnya di sini */}
          </Route>
        </Routes>
      </AuthProvider>
    </BrowserRouter>
  );
}

Poin Kritis yang Sering Diabaikan

Masalah race condition — kalau ada 3 request bersamaan dan semuanya dapat 401, tanpa queue mechanism mereka semua akan mencoba refresh secara bersamaan, dan yang kedua & ketiga akan gagal karena refresh token sudah di-rotate. Solusinya sudah ada di interceptor di atas dengan isRefreshing flag dan failedQueue.

Refresh token rotation — setiap kali refresh token digunakan, server harus issue refresh token baru dan invalidate yang lama. Ini mencegah token lama dipakai kalau bocor.

httpOnly cookie untuk refresh token — di Laravel, set cookie dengan:

// AuthController.php
return response()->json(['accessToken' => $accessToken, 'user' => $user])
    ->cookie('refresh_token', $refreshToken, 60 * 24 * 7, '/', null, true, true);
    //         name            value           minutes  path domain  secure  httpOnly

Silent refresh saat app load — ini penting! Kalau user refresh halaman, access token di memory hilang. Tapi kalau refresh token di cookie masih valid, kita bisa silently re-authenticate tanpa user perlu login ulang.

Struktur folder yang gw rekomendasikan untuk project ini:

src/
├── lib/
│   ├── axios.js          # instance + interceptors
│   └── tokenStorage.js   # in-memory token store
├── contexts/
│   └── AuthContext.jsx   # user state + auth methods
├── components/
│   └── ProtectedRoute.jsx
└── pages/
    ├── Login.jsx
    └── Dashboard.jsx

Refresh Token: Disimpan Di Mana?

Ada 2 tempat yang terlibat — sisi client dan sisi server:


Sisi Client → httpOnly Cookie

Refresh token disimpan di httpOnly cookie yang di-set oleh server saat response login. Kamu tidak perlu dan tidak bisa menyimpannya secara manual dari JavaScript.

Set-Cookie: refresh_token=eyJhbG...; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh; Max-Age=604800

Cookie ini:

  • Tidak bisa dibaca JS (document.cookie tidak akan menampilkannya)
  • Otomatis dikirim browser ke server setiap request ke domain yang sama
  • Hanya dikirim ke path /auth/refresh — bukan ke semua endpoint

Sisi Server → Database

Server juga harus menyimpan refresh token di database untuk keperluan validasi dan revocation.

-- Tabel refresh_tokens di Laravel
CREATE TABLE refresh_tokens (
    id          BIGINT PRIMARY KEY,
    user_id     BIGINT REFERENCES users(id),
    token       VARCHAR(255) UNIQUE,  -- bisa di-hash
    expires_at  TIMESTAMP,
    revoked_at  TIMESTAMP NULL,       -- untuk logout / revoke
    created_at  TIMESTAMP
);

Kapan Disimpan?

Berikut timeline lengkapnya:---

Ringkasan Singkat

Client (Browser) Server (DB)
Disimpan sebagai httpOnly Cookie Row di tabel refresh_tokens
Kapan disimpan Saat login & setiap refresh Saat login & setiap refresh (rotation)
Kapan dihapus Saat logout (Max-Age=0) Saat logout (revoked_at) atau expired
Bisa dibaca JS? ❌ Tidak
Dikirim otomatis? ✅ Ya, oleh browser

Yang Penting Dipahami

Kenapa harus disimpan di DB juga? Karena kalau hanya ada di cookie, server tidak bisa "membatalkan" token tersebut sebelum expired. Misalnya user ganti password, atau ada aktivitas mencurigakan — server harus bisa revoke semua sesi aktif. Itu hanya bisa dilakukan kalau server punya record-nya di DB.

Kenapa path cookie dibatasi ke /auth/refresh? Supaya cookie tidak dikirim ke semua endpoint API — hanya dikirim saat memang butuh refresh. Ini mengurangi exposure refresh token.

Di React, kamu tidak perlu nulis kode apapun untuk menyimpan refresh token. Itu murni urusan browser (cookie) dan server (DB). Yang kamu kelola di React hanya access token yang disimpan di memory seperti yang sudah kita bahas sebelumnya.