기존 localStorage 기반 JWT 인증 방식에서 httpOnly Cookie 기반으로 전환한 이유와 과정, 그리고 실제 코드 적용 내용을 정리했다.
보안(XSS), 자동로그인, 개발 편의성 측면에서 어떤 차이가 있는지 비교하고, 실제 구현까지 다룬다.
처음 FinTrack 인증을 구현했을 때는 가장 흔한 방식인 JWT를 localStorage에 저장하는 방식을 사용했었다.
authStore.ts
const useAuthStore = create<AuthStore>((set) => ({
token: localStorage.getItem("token") ?? null,
user: null,
login: (token, user) => {
localStorage.setItem("token", token);
set({ token, user });
},
logout: () => {
localStorage.removeItem("token");
set({ token: null, user: null });
},
}));
이전 axios.ts
모든 요청에 Authorization 헤더 수동 첨부
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
기존에는 로그인 후 서버에서 받은 토큰을 localStorage에 저장하고, 이후 모든 API 요청 헤더에 Bearer {token}을 붙이는 구조였다.
물론 이 구조도 동작은 하지만 두 가지 불편함이 있었다.
아이디 저장, 자동로그인 기능이 없음 — 매번 이메일/비밀번호를 다시 입력해야 함
보안 우려 — localStorage는 JavaScript로 접근 가능해 XSS 공격에 취약
실제로 많이 쓰는 서비스들을 보면 로그인 화면에 항상 "아이디 저장"과 "자동로그인" 체크박스가 있다.
사용자 입장에서 당연히 있어야 할 UX인데 빠져 있다고 생각했고, 이를 구현하려면 인증 방식 자체를 재검토해야 했다.
구현에 앞서, 웹 인증의 가장 기본이 되는 두 개념을 간단히 정리했다.
Session ID를 발급해 쿠키에 담아 보내고, 실제 중요 데이터는 서버의 메모리나 DB에 보관보통 "세션 방식"과 "JWT 방식"을 대립되는 개념으로 보지만, 사실 JWT는 데이터의 형태일 뿐이고 쿠키는 데이터를 실어 나르는 바구니
localStorage(서랍)에 넣어두고 꺼내 씀 → XSS 공격에 취약httpOnly 쿠키 (자물쇠 달린 바구니)에 담아 보냄 → 보안성 향상localStorage JWT vs httpOnly Cookie 두 방식을 비교해서 정리했다.
| 항목 | localStorage JWT | httpOnly Cookie |
|---|---|---|
| 저장 위치 | 브라우저 localStorage | 브라우저 Cookie (httpOnly) |
| JS 접근 | 가능 | 불가 |
| XSS 취약성 | 있음 | 없음 |
| CSRF 취약성 | 없음 | 있음 (SameSite로 완화 가능) |
| 자동 전송 | 없음 (수동 첨부 필요) | 있음 (브라우저가 자동 첨부) |
| 자동로그인 | 직접 구현 필요 | 용이 (Max-Age로 서버 제어) |
보안과 자동로그인 구현의 용이성 두 가지 이유였다.
localStorage에 토큰을 저장하면 XSS(Cross-Site Scripting) 공격으로 토큰이 탈취될 수 있다.
httpOnly 쿠키는 JavaScript에서 document.cookie로 접근이 아예 불가능하기 때문에 이 위험이 사라진다.
자동로그인의 핵심은 브라우저를 닫아도 인증 상태가 유지되는 것이다.
쿠키에는 Max-Age 속성이 있어 서버에서 쿠키의 수명을 제어할 수 있다.
일반 로그인:
서버가 Max-Age 없이 쿠키 발급 → 브라우저 종료 시 쿠키 소멸 (세션 쿠키)
자동로그인:
서버가 Max-Age: 604800(7일) 등으로 쿠키 발급 → 브라우저 종료 후에도 유지
이 구분을 클라이언트에서는 로그인 요청 시 autoLogin: true/false 플래그만 넘기면 되고, 실제 쿠키 수명 제어는 서버가 담당합니다.
axios 인스턴스: withCredentials: true쿠키를 전송하려면 withCredentials: true 설정이 필수이다.
// shared/api/axios.ts
export const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
withCredentials: true, // 쿠키 자동 전송 설정
headers: {
"Content-Type": "application/json",
},
});
// Response Interceptor: 401 응답 시 자동 refresh
let isRefreshing = false;
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
if (!isRefreshing) {
isRefreshing = true;
try {
await api.post("/api/auth/refresh"); // 쿠키 내 refresh token 활용
isRefreshing = false;
return api(originalRequest); // 원래 요청 재시도
} catch {
isRefreshing = false;
useAuthStore.getState().logout();
window.location.href = "/login"; // 실패 시 로그인 페이지로
}
}
}
return Promise.reject(error);
}
);
authStore: 토큰 제거이제 JS에서 토큰을 직접 다루지 않기 때문에 token 상태를 제거했다.
type AuthStore = {
user: User | null;
login: (user: User) => void;
logout: () => void;
};
const savedUser = localStorage.getItem("user");
export const useAuthStore = create<AuthStore>((set) => ({
user: savedUser ? JSON.parse(savedUser) : null,
login: (user) => {
localStorage.setItem("user", JSON.stringify(user));
set({ user });
},
logout: () => {
localStorage.removeItem("user");
set({ user: null });
},
}));
LoginPage: 아이디 저장 & 자동로그인 UIfunction LoginPage() {
const [email, setEmail] = useState("");
const [saveId, setSaveId] = useState(false);
const [autoLogin, setAutoLogin] = useState(false);
useEffect(() => {
const savedEmail = localStorage.getItem("savedEmail");
if (savedEmail) {
setEmail(savedEmail);
setSaveId(true);
}
}, []);
const handleLogin = async () => {
const res = await loginApi({ email, password, autoLogin });
// 아이디 저장 로직 (이메일만 보관)
if (saveId) {
localStorage.setItem("savedEmail", email);
} else {
localStorage.removeItem("savedEmail");
}
login({ name: res.name, email: res.email });
navigate("/dashboard");
};
return (
<>
{/* ...이메일/비밀번호 입력란... */}
<label>
<input type="checkbox" checked={saveId} onChange={(e) => setSaveId(e.target.checked)} />
아이디 저장
</label>
<label>
<input type="checkbox" checked={autoLogin} onChange={(e) => setAutoLogin(e.target.checked)} />
자동로그인
</label>
</>
);
}
| 항목 | 이전 (localStorage) | 이후 (httpOnly Cookie) |
|---|---|---|
| 토큰 저장 위치 | 브라우저 localStorage | httpOnly Cookie (서버 발급) |
| JS 토큰 접근 | 가능 | 불가 (보안 향상) |
| API 인증 방식 | Authorization 헤더 수동 첨부 | 브라우저 쿠키 자동 전송 |
| 토큰 재발급 | 없음 (직접 구현 필요) | interceptor를 통한 자동 처리 |
| 아이디 저장 | 없음 | savedEmail localStorage 저장 |
| 자동로그인 | 없음 | autoLogin 플래그로 서버가 제어 |
httpOnly 쿠키로 전환하면서 보안성도 높이고, 아이디 저장과 자동로그인까지 깔끔하게 구현할 수 있었다.
특히 자동로그인은 클라이언트 로직이 아닌 서버의 쿠키 설정만으로 해결된다는 점이 기억에 남았다.