액세스 토큰을 어떻게 메모리에서 관리할까
localStorage는 XSS에 취약해서 액세스 토큰을 메모리 변수에 뒀습니다. 그랬더니 새로고침할 때마다 토큰이 날라가버렸습니다...
geonoooo
1 min read
액세스 토큰을 어디에 둘지부터 정해야 했습니다. localStorage는 스크립트 한 줄로 읽힙니다. XSS가 한 번 뚫리면 보안상 위험하니까 제외했습니다.
그래서 메모리에 넣었습니다.
// fe/src/shared/http.ts
let accessToken: string | null = null;JS 힙에만 있으니 localStorage를 훑는 스크립트로는 못 가져갑니다. 그런데 바로 다음 문제가 나옵니다.
새로고침하면 사라집니다. 그래서 로그인이 풀려버립니다. 그래서 전역으로 관리해야지 라는 생각으로 zustand를 도입했다가 이것도 어짜피 휘발되고 저장하려고하면 이것 또한 localstroage에 넣는건데 라는 걸 깨달아버립니다.
이 부분에 대해서 고민하고 자세하게 분석해보기로 했습니다.
NOTE
환경
React 19 + TanStack Router/Query, fetch를 직접 감싼 클라이언트. 백엔드는 FastAPI. access 토큰 30분, refresh 쿠키 12시간입니다.
이 글은 로그인한 뒤의 세션 유지만 다룹니다. 그 앞 단계인 회원가입 이메일 인증은 NestJS 이메일 인증을 구현하다가 SMTP 포트까지 파고들었다에 적었습니다.
전제 — refresh 토큰은 JS가 읽을 수 없다
HttpOnly 쿠키에 들어 있어서 document.cookie로 꺼낼 수 없습니다. 막히는 대상은 브라우저가 아니라 스크립트입니다. 브라우저는 오히려 이 토큰을 다루는 유일한 주체입니다 — 받아서 저장하고, 요청에 자동으로 붙여 보냅니다.
| 토큰 | 저장 위치 | 수명 | 프론트가 읽을 수 있나 |
|---|---|---|---|
| access | 메모리 변수 | 30분 | 읽는다. 헤더에 직접 붙인다 |
| refresh | HttpOnly 쿠키 | 12시간 | 못 읽는다 |
refresh 토큰은 코드에 등장하지 않습니다. 저장도 전송도 브라우저가 합니다.
Set-Cookie는 저장소가 아니라 응답 헤더 이름입니다. 서버는 쿠키를 갖고 있지 않고, 응답에 텍스트 한 줄을 얹어 보낼 뿐입니다. 그걸 받은 브라우저가 자기 쿠키 저장소에 넣습니다.
| 순서 | 주체 | 하는 일 |
|---|---|---|
| 1 | 서버 | 응답에 Set-Cookie: refresh_token=... 헤더를 얹어 보낸다 |
| 2 | 브라우저 | 그 헤더를 보고 자기 쿠키 저장소에 저장한다 |
| 3 | 브라우저 | 이후 /api/v1/auth 요청마다 Cookie: 헤더를 붙여 보낸다 |
Set-Cookie(응답)와 Cookie(요청)는 방향이 다른 별개의 헤더입니다. 내 코드가 하는 일은 credentials: 'include' 한 줄로 "쿠키를 실어 보내라"고 허용하는 것뿐입니다.
res = await fetch(url, {
method,
signal,
// refresh 쿠키가 오갈 유일한 통로다. 빠지면 세션이 30분마다 끊긴다.
credentials: "include",
headers: {
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
});JS가 못 읽는다는 게 핵심입니다. XSS가 뚫려도 refresh 토큰은 못 빼갑니다. access 토큰을 메모리에 둔 것과 같은 이유입니다.
오해 ① 쿠키는 401을 받고 나서 심는 게 아니다
처음 그린 그림은 이랬습니다.
새로고침하면 액세스 토큰이 없어진다 →
/auth/me가 401 → 이때 쿠키에 리프레시를 넣는다
마지막이 틀렸습니다. 401은 쿠키를 심는 시점이 아닙니다. 쿠키는 로그인할 때 이미 심어져 있습니다.
보내는 쪽은 평범합니다.
POST /api/v1/auth/login HTTP/1.1
Content-Type: application/json
{ "email": "...", "password": "..." }돌아오는 응답에 둘이 같이 실려 있습니다.
HTTP/1.1 200 OK
Content-Type: application/json
Set-Cookie: refresh_token=8f3a...; HttpOnly; Secure; Path=/api/v1/auth; Max-Age=43200
{ "access_token": "eyJ..." }응답 하나가 두 가지를 동시에 줍니다. 본문의 access_token은 내가 받아서 메모리에 넣고, 헤더의 Set-Cookie는 브라우저가 받아서 저장합니다. 내 코드는 후자를 보지도 못합니다.
그래서 새로고침 후의 401은 이렇게 읽어야 맞습니다.
401이 난다 → 이미 들어 있던 쿠키를 브라우저가
/auth/refresh에 실어 보낸다
401은 신호일 뿐입니다. 재료는 처음부터 브라우저에 있었습니다.
오해 ② 갱신 방향이 반대였다
두 번째로 이렇게 생각했습니다.
액세스 토큰을 다시 발급하는 게 아니라, 리프레시를 10분마다 계속 갱신해주는 것 아닌가
"액세스를 발급하는 게 아니라"가 틀렸습니다. /auth/refresh가 돌려주는 것은 access 토큰입니다. refresh로 access를 만드는 방향입니다.
그리고 주기적으로 도는 타이머도 없습니다. 클라이언트에 setInterval은 한 줄도 없습니다. 갱신은 요청이 나가야 할 때만 일어납니다. 401을 받았거나, 토큰이 없는 걸 이미 알 때입니다.
POST /api/v1/auth/refresh HTTP/1.1
Cookie: refresh_token=8f3a...본문이 없습니다. Cookie 헤더는 내가 붙인 게 아니라 브라우저가 붙인 것입니다. 그리고 돌아오는 건 access 토큰입니다.
HTTP/1.1 200 OK
Content-Type: application/json
Set-Cookie: refresh_token=c1d2...; HttpOnly; Secure; Path=/api/v1/auth
{ "access_token": "새 토큰" }응답에 Set-Cookie가 또 있습니다. 갱신할 때마다 refresh 쿠키가 새것으로 교체됩니다. 이 교체가 뒤에 나올 동시 요청 문제의 원인입니다.
여기서 자연스럽게 따라온 질문이 하나 더 있었습니다. 새로고침을 세 번 하면 액세스 토큰이 세 번 생기냐는 것입니다. 그렇습니다. 새로고침마다 메모리가 비고, 그때마다 새 access 토큰을 받습니다. 30분짜리 토큰을 3분 쓰고 버리는 일이 반복돼도 정상입니다.
오해 ③ refresh 토큰을 받는 API는 따로 없다
세 번째가 가장 오래 걸렸습니다. 명세서에 API가 login과 refresh 둘뿐이었습니다.
로그인하면 액세스 토큰만 받는 것 아닌가. refresh 토큰은 대체 어디서 받는가
Set-Cookie가 헤더 한 줄이라는 게 답입니다. 이미 보내는 로그인 응답에 얹으면 끝이라, "refresh 토큰 주세요" 전용 엔드포인트를 만들 이유가 없습니다. 그런 걸 호출하려면 이미 로그인이 돼 있어야 하는데, 그럼 로그인 응답에 넣는 게 맞습니다.
엔드포인트가 둘인 건 맞습니다. 나뉘지 않은 건 엔드포인트가 아니라 refresh 토큰이 오는 통로였습니다. 본문이 아니라 헤더입니다.
쿠키 설정을 보면 의도가 더 분명합니다. Path=/api/v1/auth로 경로가 좁혀져 있어서 이렇게 갈립니다.
| 요청 | refresh 쿠키 |
|---|---|
POST /api/v1/auth/refresh | 실린다 |
GET /api/v1/reports | 안 실린다 |
보고서 목록을 부를 때는 refresh 토큰이 아예 네트워크에 나가지 않습니다. 필요한 곳에만 흘리려고 좁혀둔 설정입니다.
그래서 새로고침이 이렇게 흘러갑니다
- 새로고침. 메모리의 access 토큰 사라짐. 쿠키는 살아남음 (브라우저가 들고 있고 JS는 못 봄)
- 라우터의
beforeLoad가/auth/me를await로 기다림. 여기서부터 6번까지 화면은 아무것도 그려지지 않는다 - Authorization 헤더 없이 나가서 401
- 클라이언트가 그 401을 받아
POST /auth/refresh— 쿠키가 실려 감 - 새 access 토큰을 받아 메모리에 심음
- 원래 요청(
/auth/me)을 그대로 다시 보냄 → 성공 - 갈 곳이 정해진 뒤 원래 보던 화면이 한 번 그려짐
2번을 처음에 잘못 봤습니다. 중간에 401이 나니까 가드가 로그인 페이지를 띄웠다가 원래 페이지로 되돌아오는 깜박임이 생길 거라고 생각했습니다. 그렇게 되지 않습니다. await가 걸려 있어서 라우터는 3~6번이 끝나기 전에 어떤 화면도 렌더하지 않습니다. 로그인 페이지든 원래 페이지든 둘 중 하나만 그려지고, 하나 그렸다가 바꾸는 일이 없습니다.
401은 네트워크 탭에 남지만 화면에는 나타나지 않습니다. 깜박임이 생기는 건 가드를 컴포넌트 안에 둘 때인데, 그 차이는 뒤에서 코드로 비교합니다.
refresh 쿠키까지 만료됐으면 4번이 실패합니다. 그럼 원래의 401을 그대로 던집니다. 갱신 실패가 아니라 401입니다. 화면이 알아야 하는 것은 "갱신에 실패했다"가 아니라 "누군지 모른다"이기 때문입니다. 이때는 7번에서 로그인 페이지가 그려집니다 — 역시 한 번만입니다.
콘솔에 401이 계속 뜨는 게 정상인가
여기서 걸린 게 있었습니다. 새로고침할 때마다 콘솔에 401이 하나씩 찍힙니다. 규모 있는 서비스에서 이게 계속 보이지는 않을 것 같았습니다.
맞습니다. 의미 없는 401이었습니다. 3번의 401은 토큰이 없어서 나는 건데, 토큰이 없다는 건 2번 시점에 이미 알고 있습니다. 알면서 한 번 보내보고 401을 받는 셈입니다.
그래서 첫 시도 전에 판정을 하나 넣었습니다.
/*
* 새로고침하면 access 토큰은 날아가고 refresh 쿠키만 남는다. 없는 줄 이미 아는데
* 401을 한 번 받아 볼 이유가 없어서, 보내기 전에 갱신하고 시작한다.
*/
const refreshedUpFront = mayRefresh && !signedOut && accessToken === null;
if (refreshedUpFront) await refreshAccessToken(base);세 줄입니다. 결과가 둘로 나뉩니다.
| 왕복 | 콘솔의 401 | |
|---|---|---|
| 전 | /auth/me(401) → /auth/refresh → /auth/me = 3 | 새로고침마다 1개 |
| 후 | /auth/refresh → /auth/me = 2 | 진짜 만료된 순간만 |
왕복이 줄어드는 것보다 두 번째 열이 더 중요했습니다. 401이 매번 뜨면 배경 소음이 됩니다. 선제 갱신을 넣으면 남는 401은 30분에 한 번, 실제로 토큰이 죽은 순간뿐입니다. 그때는 콘솔의 401이 정보가 됩니다.
함정 — 갱신이 두 번 돌면 원래보다 나쁘다
선제 갱신을 넣으니 새 문제가 생겼습니다. 선제 갱신이 실패하는 경우입니다.
갱신에 실패하면 access 토큰은 여전히 없습니다. 그 상태로 본 요청이 나가서 401을 받고, 401 분기가 또 갱신하러 갑니다. 왕복이 3이 아니라 4로 늘어서 넣기 전보다 나빠집니다.
refreshedUpFront를 401 조건에 넣어 막았습니다.
if (
first.res.status === 401 &&
mayRefresh &&
!signedOut &&
!refreshedUpFront // ← 위에서 이미 갱신해 봤으면 다시 하지 않는다
) {
if (await refreshAccessToken(base)) {
const second = await attempt(url, options);
if (second.res.ok) return second.parsed as T;
throw toError(second);
}
}
throw toError(first);방금 갱신에 실패했는데 한 번 더 한다고 답이 달라지지 않습니다.
같은 블록에서 signedOut은 반대로 처리했습니다. 위에서 계산한 값을 재사용하고 싶었지만 그러면 안 됩니다. 요청이 날아가 있는 동안 사용자가 로그아웃했을 수 있습니다. 그 사이에 갱신이 돌면 방금 나간 사람의 세션이 되살아납니다. 경로 판정인 mayRefresh만 재사용하고 signedOut은 그때그때 다시 읽습니다.
동시 요청 — 갱신은 한 번만 돌아야 한다
새로고침 직후에는 화면 여러 곳이 한꺼번에 401을 받습니다. 카드와 표가 같이 뜨는 화면이면 흔합니다.
그런데 서버는 갱신할 때마다 refresh 쿠키를 새것으로 갈아 끼웁니다. 동시에 두 번 부르면 뒤엣것이 앞엣것을 무효로 만들고 둘 다 로그아웃됩니다.
진행 중인 갱신 Promise 하나를 재사용해서 막았습니다.
let refreshing: Promise<boolean> | null = null;
function refreshAccessToken(base: string): Promise<boolean> {
refreshing ??= attempt(`${base}${REFRESH_PATH}`, { method: "POST" })
.then(({ res, parsed }) => (res.ok ? readToken(parsed) : null))
.catch(() => null)
.then((token) => {
// 갱신에 실패했으면 들고 있던 토큰도 이미 죽은 것이다. setAccessToken이
// 아니라 직접 넣는다 — 갱신은 로그아웃 빗장을 풀 일이 아니다.
accessToken = token;
return token !== null;
})
.finally(() => {
refreshing = null;
});
return refreshing;
}??= 한 줄이 전부입니다. 이미 갱신이 돌고 있으면 그 Promise를 그대로 돌려줍니다.
로그인 페이지는 깜박이지 않는다
새로고침 직후 잠깐이라도 401 상태를 지나가니, 가드가 로그인 페이지를 한 번 띄웠다가 되돌아오지 않을까 걱정했습니다. 구조상 그렇게 되지 않습니다.
beforeLoad: async ({ context, location }) => {
if (location.pathname === LOGIN) return;
try {
const me = await context.queryClient.ensureQueryData(meQuery()); // ← 기다린다
if (canUseApp(me)) return;
} catch (error) {
if (!needsSignIn(error)) return;
}
throw redirect({ to: LOGIN, search: { next: location.href } });
};await가 걸려 있고, 라우터는 이게 끝나기 전에 어떤 화면도 그리지 않습니다. 갈 곳이 정해진 뒤에 한 번만 그립니다.
깜박이는 건 이렇게 짤 때입니다.
// 흔한 깜박임 구조
function App() {
const { data: me } = useQuery(meQuery()); // 처음엔 undefined
useEffect(() => {
if (!me) navigate("/login"); // 이미 한 프레임 그려진 뒤
}, [me]);
return <Dashboard />; // ← 이게 먼저 보였다가 사라짐
}컴포넌트가 먼저 그려지고 나서 확인하니 잘못된 화면이 한 프레임 스칩니다. 가드는 컴포넌트가 아니라 라우팅 단계에 있어야 합니다.
대신 빈 화면 구간은 있습니다. 갱신과 /auth/me 두 왕복 동안 라우터가 대기합니다. 왕복을 셋에서 둘로 줄인 게 이 시간을 줄이려던 것이기도 합니다.
여기에 스켈레톤(pendingComponent)은 아직 넣지 않았습니다. 얼마나 걸리는지 모르는 상태에서 넣으면 깜박임을 하나 만들 수 있습니다. 100ms 만에 끝나는데 스켈레톤을 띄우면 그게 번쩍하고 사라집니다. 실제로 재보고 길면 그때 넣는 게 맞습니다.
테스트가 증명하지 못한 것
이 로직은 fetch를 가짜로 세워 단위 테스트로 고정했습니다. 401을 받으면 갱신하는지, 갱신을 두 번 하지 않는지, 동시 요청에서 갱신이 한 번만 도는지, 로그아웃 뒤에 되살아나지 않는지까지는 검증됩니다.
검증 안 되는 게 있습니다. 쿠키입니다. Node에서 fetch를 가짜로 세우면 쿠키라는 개념 자체가 없습니다. credentials: 'include'를 붙였다는 것만 확인했지, 그게 실제로 refresh 쿠키를 실어 나르는지는 못 봤습니다. SameSite·Secure·Path, 오리진이 다를 때의 Access-Control-Allow-Credentials는 전부 브라우저가 하는 일입니다.
이 중 하나만 어긋나면 갱신이 조용히 401로 떨어지고, 사용자는 새로고침할 때마다 로그인 화면으로 튕깁니다. 백엔드가 붙으면 가장 먼저 볼 지점입니다.
세 가지 오해가 나온 이유
오해 세 개가 전부 같은 전제에서 나왔습니다. refresh 토큰을 내 코드가 다뤄야 한다고 생각한 것입니다. 쿠키를 내가 넣고, 내가 갱신 주기를 관리하고, 받아오는 API가 따로 있을 거라고 봤습니다.
실제로는 반대입니다. 프론트가 refresh 토큰에 대해 하는 일은 credentials: 'include' 한 줄뿐입니다. 못 읽기 때문에 안전한 것이고, 못 읽으니 관리할 것도 없습니다.
코드로 남은 건 세 부분입니다.
- 선제 갱신 — 새로고침 직후, 토큰이 없는 걸 이미 알면 401을 받아보지 않고 바로 갱신한다. 왕복이 셋에서 둘로 줄고, 새로고침마다 찍히던 401이 사라진다.
refreshedUpFront가드 — 선제 갱신이 실패한 뒤 401 분기가 또 갱신하면 왕복이 넷으로 늘어난다. 선제 갱신을 이미 했으면 401 분기를 건너뛴다.refreshing단일 실행 — 갱신할 때마다 쿠키가 교체되므로, 동시에 두 번 부르면 뒤엣것이 앞엣것을 무효로 만들어 둘 다 로그아웃된다. 진행 중인 Promise를 재사용해 한 번만 돌린다.
세 가지 모두 처음부터 생각하고 구현하지 못했습니다. 콘솔에 매번 찍히는 401이 정상인지 확인하다가 선제 갱신이 나왔고, 그걸 넣으면서 나머지 둘이 필요해졌습니다. 두 번째와 세 번째는 첫 번째를 넣지 않았으면 만나지 않았을 문제입니다.
HttpOnly가 막는 것과 막지 못하는 것
시작은 저장 위치를 고르는 문제라고 생각했습니다. 실제로는 스크립트가 값을 읽을 수 있는지를 고르는 문제였습니다.
| 저장 위치 | XSS가 값을 읽나 |
|---|---|
localStorage | 직접 읽는다 |
| 메모리 변수 | 직접은 못 읽는다. fetch를 가로채는 식으로 우회 가능 |
HttpOnly 쿠키 | 못 읽는다 |
여기까지가 이 글에서 고른 근거입니다. 그런데 하나 더 있습니다.
HttpOnly가 막는 것은 값이고, 요청은 아닙니다. XSS가 들어오면 refresh 토큰 값을 못 봐도 /auth/refresh를 직접 부를 수 있습니다. 브라우저가 쿠키를 붙여주기 때문입니다. 받은 access 토큰으로 API를 호출하는 것까지 가능합니다.
정리하면 이렇습니다.
- 막는다 — 토큰을 서버 밖으로 빼내 다른 곳에서 재사용하는 것
- 막지 못한다 — 그 페이지 안에서 인증된 요청을 보내는 것
HttpOnly 쿠키는 XSS 대책의 전부가 아니라 피해 범위를 줄이는 조치입니다. 토큰이 유출되면 공격자가 자기 환경에서 계정을 계속 쓸 수 있고, 유출되지 않으면 그 탭이 열려 있는 동안으로 제한됩니다. 차이가 작지 않아서 이 선택을 했지만, XSS 자체를 막는 일은 그대로 남아 있습니다.
Comments