OAuth 토큰, 어디에 저장할까 (쿠키 vs localStorage)
로그인 후 받은 토큰을 브라우저 어디에 저장해야 하는지, 선택지별 장단점과 고르는 기준을 정리합니다.
결론부터 말하면, 리프레시 토큰은 HttpOnly 쿠키에, 액세스·ID 토큰은 메모리(또는 짧은 수명의 localStorage)에 나눠 저장하는 것이 안전합니다. 토큰을 한곳에 다 넣지 않는 것이 핵심입니다.
로그인을 붙이면 곧 마주치는 질문이 있습니다. "받은 토큰을 어디에 두지?" localStorage에 넣으라는 글도 있고, 절대 넣지 말라는 글도 있습니다. 정답은 "상황에 따라 다르다"이지만, 고르는 기준은 아래처럼 분명히 정리할 수 있습니다.
먼저, 토큰의 종류

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에 약합니다. 서로 반대입니다.
어떻게 고르나
토큰 종류에 따라 저장 위치를 나누는 것이 핵심입니다. 위 표를 결정 흐름으로 바꾸면 다음과 같습니다.
- 리프레시 토큰(긴 수명) → HttpOnly 쿠키에 둡니다. JS가 못 읽으니 XSS로 탈취되지 않습니다. CSRF는
SameSite=Strict/Lax로 막습니다. - 액세스/ID 토큰(짧은 수명) → 메모리가 가장 안전합니다. 다만 새로고침마다 사라지므로, 그때 리프레시 토큰으로 다시 받아옵니다. 편의를 더 원하면 localStorage도 선택지지만, XSS 위험을 감수하는 대신 토큰 수명을 짧게 유지해야 합니다.
- 토큰을 그대로 주소창/프런트에 노출하지 않기 → 교환은 서버 한 단계를 거칩니다(BFF 패턴).
권장 패턴 (BFF + 분리 저장)

가장 균형 잡힌 방법은 서버(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)도 고려해야 합니다.
- XSS에는 강하지만 CSRF에 노출됩니다.
- sessionStorage는요?
- 탭마다 격리돼 탭 간 로그인 공유가 안 됩니다. UX가 나빠 잘 쓰지 않습니다.
참고자료
- OWASP: HTML5 Security / Token Storage Cheat Sheet
- MDN:
Set-Cookie,SameSite, Web Storage API