본문으로 건너뛰기

OAuth 토큰, 어디에 저장할까 (쿠키 vs localStorage)

· 약 6분

로그인 후 받은 토큰을 브라우저 어디에 저장해야 하는지, 선택지별 장단점과 고르는 기준을 정리합니다.

결론부터 말하면, 리프레시 토큰은 HttpOnly 쿠키에, 액세스·ID 토큰은 메모리(또는 짧은 수명의 localStorage)에 나눠 저장하는 것이 안전합니다. 토큰을 한곳에 다 넣지 않는 것이 핵심입니다.

로그인을 붙이면 곧 마주치는 질문이 있습니다. "받은 토큰을 어디에 두지?" localStorage에 넣으라는 글도 있고, 절대 넣지 말라는 글도 있습니다. 정답은 "상황에 따라 다르다"이지만, 고르는 기준은 아래처럼 분명히 정리할 수 있습니다.

먼저, 토큰의 종류

액세스·ID 토큰은 수명이 짧고, 리프레시 토큰은 수명이 길어 더 민감하다

OAuth/OIDC 로그인을 하면 보통 토큰 두세 개를 받습니다.

  • 액세스 토큰 / ID 토큰: API를 부를 때 쓰는 토큰. 수명이 짧습니다(보통 분 단위).
  • 리프레시 토큰: 액세스 토큰이 만료되면 새로 받아오는 데 쓰는 토큰. 수명이 깁니다.

수명이 긴 리프레시 토큰이 더 민감합니다. 탈취되면 오래 악용되기 때문입니다. 그래서 둘을 같은 곳에 둘지, 나눠 둘지가 핵심 결정이 됩니다.

저장할 수 있는 곳

브라우저에서 토큰을 둘 수 있는 자리는 크게 넷입니다.

저장소한 줄 설명
메모리(JS 변수)탭이 살아 있는 동안만. 새로고침하면 사라짐
localStorage도메인별 영구 저장. JS로 읽고 씀
sessionStorage탭 단위 저장. 탭 닫으면 사라짐
쿠키요청에 자동 첨부. HttpOnly면 JS가 못 읽음

무엇을 막아 주나

저장 위치를 고르는 건 결국 어떤 공격을 막느냐의 문제입니다.

토큰 저장 위치별 비교와 선택 기준 결정 흐름
기준localStorage쿠키 (HttpOnly)메모리
XSS(스크립트 탈취) 방어❌ JS가 읽음✅ JS 접근 불가△ 변수 접근 가능하나 영속 안 됨
CSRF 방어✅ 자동 전송 안 됨❌ 자동 전송됨(SameSite로 완화)
크기 한계넉넉(5MB+)약 4KB넉넉
새로고침 후 유지
탭 간 공유
핵심

완벽한 한 곳은 없습니다. localStorage는 XSS에 약하고 CSRF에 강하며, HttpOnly 쿠키는 XSS에 강하고 CSRF에 약합니다. 서로 반대입니다.

어떻게 고르나

토큰 종류에 따라 저장 위치를 나누는 것이 핵심입니다. 위 표를 결정 흐름으로 바꾸면 다음과 같습니다.

  1. 리프레시 토큰(긴 수명)HttpOnly 쿠키에 둡니다. JS가 못 읽으니 XSS로 탈취되지 않습니다. CSRF는 SameSite=Strict/Lax로 막습니다.
  2. 액세스/ID 토큰(짧은 수명) → 메모리가 가장 안전합니다. 다만 새로고침마다 사라지므로, 그때 리프레시 토큰으로 다시 받아옵니다. 편의를 더 원하면 localStorage도 선택지지만, XSS 위험을 감수하는 대신 토큰 수명을 짧게 유지해야 합니다.
  3. 토큰을 그대로 주소창/프런트에 노출하지 않기 → 교환은 서버 한 단계를 거칩니다(BFF 패턴).

권장 패턴 (BFF + 분리 저장)

BFF가 토큰을 받아 리프레시는 HttpOnly 쿠키로, 액세스·ID는 프런트로 나눠 저장하는 흐름

가장 균형 잡힌 방법은 서버(BFF, Backend for Frontend)를 한 단계 두고, 토큰을 종류별로 나눠 저장하는 것입니다.

[로그인] → 인증 서버
→ BFF가 토큰 수령
→ 리프레시 토큰: Set-Cookie (HttpOnly, Secure, SameSite)
→ 액세스/ID 토큰: 프런트로 전달 → 메모리(또는 localStorage)

API를 부를 때는 액세스 토큰을 헤더에 싣고, 401이 오면 쿠키에 담긴 리프레시 토큰으로 갱신합니다.

// 요청마다 액세스 토큰 첨부
apiClient.interceptors.request.use((config) => {
const token = getAccessToken(); // 메모리 또는 localStorage
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});

// 401 → 리프레시 (refresh 토큰은 HttpOnly 쿠키라 JS가 직접 안 보냄)
apiClient.interceptors.response.use(null, async (error) => {
if (error.response?.status === 401) {
await apiClient.post("/auth/refresh"); // 쿠키 자동 첨부
return apiClient(error.config); // 원요청 재시도
}
throw error;
});

리프레시 토큰을 굽는 쪽(BFF) 예시:

Set-Cookie: refresh_token=<value>; HttpOnly; Secure; SameSite=Lax; Path=/auth; Max-Age=1209600

흔한 함정

  • 쿠키 4KB 한계: 토큰을 암호화해 쿠키에 넣다 보면 4KB를 넘기 쉽습니다. 한계를 넘으면 브라우저가 오류 없이 쿠키를 버립니다. 저장된 줄 알았는데 비어 있어 401이 반복됩니다. (이 사고를 직접 겪은 이야기는 회고 글에 적었습니다.)
  • 도메인 격리: A 도메인에서 localStorage에 넣은 토큰은 B 도메인에서 못 읽습니다. 토큰은 그것을 쓸 도메인 쪽에 저장해야 합니다.
  • localStorage를 쓰면서 토큰 수명을 길게: 최악의 조합입니다. XSS에 노출된 채로 오래 유효합니다. localStorage를 쓴다면 수명을 짧게 두고 CSP 같은 완화책을 함께 적용합니다.

Q&A

  • 그냥 다 localStorage에 넣으면 안 되나요?
    • 동작은 합니다. 다만 XSS 한 번에 긴 수명 토큰까지 털립니다. 최소한 리프레시 토큰만이라도 HttpOnly 쿠키로 빼는 걸 권합니다.
  • HttpOnly 쿠키만 쓰면 제일 안전한 것 아닌가요?
    • XSS에는 강하지만 CSRF에 노출됩니다. SameSite와 CSRF 토큰으로 막아야 하고, 크기 한계(4KB)도 고려해야 합니다.
  • sessionStorage는요?
    • 탭마다 격리돼 탭 간 로그인 공유가 안 됩니다. UX가 나빠 잘 쓰지 않습니다.

참고자료

  • OWASP: HTML5 Security / Token Storage Cheat Sheet
  • MDN: Set-Cookie, SameSite, Web Storage API