JWT, Access Token dan Refresh Token
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.cookietidak 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.