Autentikasi di SPA (Single Page Application) dan aplikasi mobile memiliki tantangan berbeda dari aplikasi web tradisional. Pilihan penyimpanan token dan alur OAuth sangat berbeda.
Auth di SPA (React, Vue, Angular):
Masalah utama: di mana menyimpan token?
| Lokasi | XSS | CSRF | Rekomendasi |
|---|---|---|---|
| localStorage | Rentan | Aman | JANGAN untuk token |
| sessionStorage | Rentan | Aman | Hindari |
| Memory (JS variable) | Aman | Aman | Terbaik untuk access token |
| HttpOnly Cookie | Aman | Perlu mitigasi | Terbaik untuk refresh token |
Pola yang direkomendasikan untuk SPA:
// auth.js — token disimpan di memory (hilang saat refresh halaman = by design)
let accessToken = null;
export function setAccessToken(token) {
accessToken = token;
}
export function getAccessToken() {
return accessToken;
}
// Saat app load: gunakan refresh token (dari HttpOnly cookie) untuk dapat access token baru
export async function initAuth() {
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include" // kirim cookie HttpOnly
});
const { accessToken: token } = await res.json();
setAccessToken(token);
} catch {
// User belum login atau refresh token expired
setAccessToken(null);
}
}
// Axios interceptor untuk auto-refresh
axios.interceptors.response.use(null, async (error) => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true;
await initAuth();
error.config.headers.Authorization = `Bearer ${getAccessToken()}`;
return axios(error.config);
}
return Promise.reject(error);
});
Auth di Mobile (OAuth 2.0 + PKCE):
Mobile apps tidak bisa menyimpan client_secret secara aman. Gunakan PKCE (Proof Key for Code Exchange):
// 1. Generate code verifier & challenge
function generatePKCE() {
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
// 2. Buka browser untuk login OAuth
const { verifier, challenge } = generatePKCE();
const authUrl = `https://auth.example.com/oauth/authorize?`
+ `client_id=myapp`
+ `&response_type=code`
+ `&redirect_uri=myapp://callback`
+ `&code_challenge=${challenge}`
+ `&code_challenge_method=S256`
+ `&scope=openid profile`;
// Buka authUrl di system browser (lebih aman dari WebView!)
// 3. Handle callback di deep link
app.on("deep_link", async (url) => {
const code = new URL(url).searchParams.get("code");
// 4. Tukar code dengan token — sertakan verifier
const tokens = await fetch("https://auth.example.com/oauth/token", {
method: "POST",
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: "myapp://callback",
client_id: "myapp",
code_verifier: verifier // server verifikasi ini cocok dengan challenge
})
}).then((r) => r.json());
// Simpan di Secure Enclave / Keystore (bukan localStorage/AsyncStorage!)
await SecureStore.setItemAsync("refresh_token", tokens.refresh_token);
});
Penyimpanan token di mobile:
- iOS: Keychain (via SecureStore di Expo)
- Android: Keystore (via Keychain Android atau SecureStore di Expo)
- JANGAN pakai AsyncStorage untuk token sensitif!
Silent refresh untuk mobile:
// Setiap kali app kembali ke foreground
AppState.addEventListener("change", async (state) => {
if (state === "active") {
const refreshToken = await SecureStore.getItemAsync("refresh_token");
if (refreshToken) await refreshTokens(refreshToken);
}
});