새벽 2시에 슬랙 알림이 울렸다. 보안팀에서 보낸 메시지였다. "XSS 취약점 발견. 사용자 토큰 탈취 가능. 긴급 대응 필요." 위에서 빨간불이 켜졌고, 나는 잠들기 직전에 노트북을 다시 열었다.
문제는 단순했다. JWT를 localStorage에 저장하고 있었다. 보안 감사 과정에서 한 입력 필드에 스크립트 인젝션이 가능한 게 발견됐고, 그 스크립트가 localStorage.getItem("token")을 호출할 수 있다는 게 증명됐다. 실제 공격이 일어난 건 아니었지만, 취약점이 존재한다는 것 자체가 문제였다.
"우리 사이트에 XSS가 없으니까 괜찮다"는 논리로 localStorage를 썼었다. 그런데 서드파티 스크립트가 포함된 페이지에서 100% XSS가 없다고 장담할 수 있는가? 분석 도구, 광고 SDK, 채팅 위젯. 전부 외부 JavaScript를 로드한다. 그중 하나라도 뚫리면 localStorage의 토큰은 그대로 노출된다.
이 사건을 계기로 인증 방식을 처음부터 다시 공부했다. 그리고 인증은 절대로 복사-붙여넣기로 구현하면 안 된다는 걸 뼈저리게 배웠다.
localStorage에서 httpOnly 쿠키로의 마이그레이션
보안팀의 권고는 명확했다. "JWT를 httpOnly 쿠키로 옮겨라." 개념은 간단하다.
// 이전: localStorage에 저장
const { accessToken } = await response.json();
localStorage.setItem("token", accessToken);
// 요청 시
fetch("/api/data", {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});// 이후: httpOnly 쿠키
// 서버에서 Set-Cookie: token=eyJhbG...; HttpOnly; Secure; SameSite=Strict
// 프론트에서는 그냥 credentials만 포함
fetch("/api/data", {
credentials: "include",
});httpOnly 쿠키는 JavaScript에서 접근이 불가능하다. document.cookie로도 읽을 수 없다. XSS가 발생해도 토큰을 빼갈 수 없다.
개념은 간단한데, 마이그레이션 과정에서 예상 못한 것들이 터졌다.
첫 번째, CORS 설정이 전부 틀어졌다. localStorage에 토큰을 넣고 Authorization 헤더로 보낼 때는 CORS가 비교적 단순했다. 쿠키를 쓰기 시작하면 credentials: "include"가 필요하고, 서버에서 Access-Control-Allow-Credentials: true를 보내야 하고, Access-Control-Allow-Origin에 와일드카드(*)를 쓸 수 없게 된다. 정확한 도메인을 지정해야 한다.
개발 환경에서 프론트(localhost:3000)와 백엔드(localhost:4000)의 포트가 다르니까, 쿠키가 안 붙는 문제가 생겼다. SameSite=None; Secure로 설정해야 하는데, Secure는 HTTPS에서만 동작한다. 로컬에서는 HTTPS가 아니니까 또 안 된다. 결국 개발 환경에서만 SameSite=Lax로 바꾸고, 프록시로 동일 도메인처럼 만들었다. 이 삽질에 하루가 날아갔다.
두 번째, CSRF 방어가 필요해졌다. localStorage + Authorization 헤더 방식은 CSRF에 강하다. 토큰이 자동으로 전송되지 않으니까. 쿠키로 바꾸면 모든 요청에 쿠키가 자동으로 붙는다. 악의적인 사이트에서 우리 API로 요청을 보내면 쿠키가 같이 간다. SameSite=Strict로 대부분 막을 수 있지만, 완벽하지는 않아서 CSRF 토큰도 추가했다.
새벽 2시의 리프레시 토큰 레이스 컨디션
마이그레이션을 끝내고 한숨 돌렸는데, 2주 후에 또 다른 버그가 터졌다. 사용자가 탭을 여러 개 열어놓으면 간헐적으로 로그아웃되는 현상이었다.
원인은 리프레시 토큰 로직에 있었다. Access Token이 만료되면 Refresh Token으로 새 Access Token을 발급받는 패턴이다.
let accessToken: string | null = null;
async function refreshAccessToken(): Promise<string> {
const response = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
});
const { accessToken: newToken } = await response.json();
accessToken = newToken;
return newToken;
}
axios.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
accessToken = null;
const newToken = await refreshAccessToken();
error.config.headers.Authorization = `Bearer ${newToken}`;
return axios.request(error.config);
}
return Promise.reject(error);
}
);
문제는 이거다. 탭 A에서 API 요청 3개가 동시에 401을 받는다. 인터셉터가 3번 동작하면서 refresh 요청이 3개 동시에 나간다. 서버에서 Refresh Token은 1회용으로 설정되어 있었다. 첫 번째 refresh 요청은 성공하지만, 두 번째와 세 번째는 이미 사용된 Refresh Token으로 요청하니까 실패한다. 서버가 "의심스러운 토큰 재사용"으로 판단하고 모든 세션을 무효화한다. 사용자 입장에서는 갑자기 로그아웃된다.
해결은 refresh 요청을 하나로 합치는 거다.
let refreshPromise: Promise<string> | null = null;
async function getAccessToken(): Promise<string> {
if (accessToken) return accessToken;
if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
이렇게 하면 동시에 여러 요청이 401을 받아도, refresh 요청은 하나만 나간다. 나머지 요청들은 같은 Promise를 await한다. 간단한 코드인데, 이걸 생각해내기까지 새벽 2시부터 5시까지 걸렸다. 멀티탭 환경에서의 토큰 갱신은 진짜 까다로운 문제다.
세션 방식이 더 나았을까?
이 삽질들을 겪고 나서 "그냥 세션 쓸 걸" 하는 생각이 들었다. 세션 기반 인증은 프론트엔드 입장에서 할 일이 거의 없다.
const response = await fetch("/api/login", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
// 이후 모든 요청에 credentials: "include"만 넣으면 됨
const userData = await fetch("/api/me", {
credentials: "include",
});쿠키는 브라우저가 자동으로 관리한다. 토큰을 저장할 곳을 고민할 필요 없다. 리프레시 토큰도 필요 없다. 세션 만료는 서버가 알아서 처리한다. "모든 기기에서 로그아웃" 같은 기능도 서버에서 세션을 삭제하면 끝이다.
세션의 단점은 서버 쪽에 있다. 세션 저장소(보통 Redis)가 필요하고, 서버가 여러 대면 세션을 공유해야 하고, 매 요청마다 세션 저장소를 조회하는 비용이 있다. 하지만 솔직히, 대부분의 서비스에서 이게 병목이 되는 경우는 드물다.
JWT를 써야 하는 진짜 이유
그래도 JWT를 쓰는 프로젝트가 많은 건 이유가 있다.
마이크로서비스 아키텍처에서 JWT가 유리하다. 서비스 A가 발급한 토큰을 서비스 B가 검증할 수 있다. 공유 세션 저장소 없이. 토큰 자체에 사용자 정보가 들어있으니까 디코딩만 하면 된다.
모바일 앱과 웹이 같은 API를 쓸 때도 JWT가 편하다. 모바일 앱에서 세션 쿠키를 관리하는 건 꽤 번거롭다.
서드파티 인증(OAuth)에서도 JWT가 자연스럽다. Google, GitHub 같은 외부 서비스의 인증 토큰을 검증하는 패턴과 잘 맞는다.
문제는 이런 이유 없이 "JWT가 모던하니까" "세션은 옛날 방식이니까"라는 이유로 JWT를 선택하는 경우다. 서버가 한 대이고, 같은 도메인이고, 웹만 서비스한다면 세션이 훨씬 간단하다. 인증은 복잡할수록 버그가 생길 확률이 높다.
JWT의 구조적 함정: 토큰 무효화
JWT의 가장 큰 약점은 발급된 토큰을 서버에서 무효화할 수 없다는 거다. 토큰 만료가 1시간이면, 비밀번호를 바꿔도 기존 토큰이 1시간 동안 유효하다.
이걸 해결하려면 서버에 블랙리스트를 관리해야 한다. "무효화된 토큰 목록"을 Redis에 저장하고, 매 요청마다 확인한다. 그런데 이렇게 하면 JWT의 장점인 "stateless"가 무색해진다. 결국 세션이랑 다를 바 없어지는 거다.
타협점은 Access Token의 만료 시간을 짧게(5분~15분) 잡고, Refresh Token으로 갱신하는 패턴이다. Access Token이 탈취되어도 짧은 시간 내에 만료되니까 피해를 최소화한다. Refresh Token은 httpOnly 쿠키에 넣고, 서버에서 관리한다.
프론트엔드에서의 인증 상태 관리
어떤 방식을 쓰든, "현재 로그인 상태인지"를 프론트에서 관리해야 한다. 여기서도 흔한 실수가 있다.
function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function checkAuth() {
try {
const response = await fetch("/api/me", {
credentials: "include",
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
}
} catch {
// 인증 실패
} finally {
setIsLoading(false);
}
}
checkAuth();
}, []);
if (isLoading) {
return <FullScreenSpinner />;
}
return (
<AuthContext.Provider value={{ user, setUser }}>
{children}
</AuthContext.Provider>
);
}isLoading 상태가 핵심이다. 이걸 빼먹으면 인증 확인이 끝나기 전에 "로그인 안 됨"으로 판단해서 로그인 페이지로 리다이렉트된다. 새로고침할 때마다 로그인 페이지가 깜빡이는 현상이 생긴다. 간단한 건데, 처음 구현할 때 놓치기 쉽다.
현실적인 선택 기준
많은 프로젝트에서 결국 "JWT를 httpOnly 쿠키에 넣는" 하이브리드 방식을 채택한다. JWT의 자기 완결성(서버가 디코딩만 하면 됨)과 쿠키의 보안성(JavaScript에서 접근 불가)을 동시에 가져간다. 이러면 프론트엔드 구현이 세션 방식과 거의 같아진다. credentials: "include"만 넣으면 끝이다. 차이는 서버 내부에서만 존재한다.
정리하면 이렇다.
- 서버 한 대, 같은 도메인, 웹만 서비스: 세션이 간단하고 안전하다.
- 마이크로서비스, 모바일 + 웹, 서드파티 인증: JWT가 유리하다.
- 어떤 방식이든 localStorage에 토큰을 넣지 않는다. httpOnly 쿠키를 쓴다.
- 리프레시 토큰을 쓴다면 동시 갱신 문제를 반드시 처리한다.
인증은 "동작하는 것"과 "안전한 것"이 다른 영역이다. Stack Overflow에서 복사한 코드가 동작은 하겠지만, 보안 감사를 통과할지는 모른다. 새벽 2시에 긴급 대응하는 경험을 한 번 하고 나면, 인증 코드를 대충 짜는 게 얼마나 위험한지 체감하게 된다. 인증은 프론트엔드에서 가장 지루하지만, 가장 신중해야 하는 부분이다.
