지난 글에서 Access Token은 짧게, Refresh Token은 길게 가져가는 이유를 정리했다. 티켓 예매 프로젝트(Ticket Run)의 재발급 API를 만들면서 바로 다음 고민이 생겼다.
Refresh Token은 수명이 긴 만큼, 한 번 탈취되면 피해도 길다. 그래서 "브라우저의 어디에 두느냐"가 Access Token보다 훨씬 중요해진다.
처음에는 localStorage, 쿠키, 메모리 중 하나를 고르는 문제처럼 보인다. 하지만 핵심은 브라우저 저장 위치는 크로스 사이트 스크립팅(Cross-Site Scripting, XSS)과 사이트 간 요청 위조(Cross-Site Request Forgery, CSRF) 같은 탈취 경로를 줄이는 문제이고, 서버 저장은 탈취 이후를 통제하는 문제라는 점이다.
이 글에서는 XSS, CSRF, 서버 통제, 구현 비용을 기준으로 저장 위치와 그 조합을 정리한다.
간단히 정리하면
- localStorage : JS로 언제든 읽을 수 있다. XSS 한 번이면 탈취된다.
- 메모리(JS 변수) : JS 영역이지만 새로고침하면 사라진다.
- HttpOnly 쿠키 : JS로 읽을 수 없다. 대신 요청에 자동으로 실려 CSRF를 신경 써야 한다.
- 서버 저장소 : 브라우저 위치가 아니다. 강제 만료와 재사용 탐지를 맡는다.
CSRF 노출을 줄이고 싶다 → Refresh Token만 쿠키에 둔다 (조합 1)
구현을 단순하게 하고 싶다 → 모두 쿠키에 두고 CSRF 방어를 더한다 (조합 2)
통제가 가장 중요하다 → Refresh Token을 서버에만 둔다 (조합 3)
localStorage
localStorage는 같은 출처(origin)의 JavaScript가 자유롭게 읽고 쓰는 저장소다. 탭을 닫아도 값이 남아 있어 구현이 가장 쉽다.
하지만 같은 출처의 모든 스크립트가 읽을 수 있다는 점이 그대로 약점이 된다.
예를 들어 게시판 입력값 하나에 XSS가 생기면, 공격 스크립트가 다음 한 줄로 토큰을 가져간다.
// 같은 출처에서 실행되는 스크립트라면 누구든 읽을 수 있다.
const token = localStorage.getItem("refreshToken");
그래서 OWASP Session Management Cheat Sheet는 localStorage 항목에서 Refresh Token을 포함한 인증 토큰을 localStorage나 sessionStorage에 저장하지 말라고 명시한다. 대안으로는 HttpOnly 쿠키를 제시한다.
즉, localStorage는 XSS 한 번에 수명이 긴 토큰을 통째로 넘겨주는 위치다.
HttpOnly 쿠키
HttpOnly 쿠키는 JavaScript(document.cookie)로 읽을 수 없고, 서버로 요청을 보낼 때만 브라우저가 실어 보내는 쿠키다. (MDN, Using HTTP cookies)
그래서 앞의 XSS 상황에서도 공격 스크립트가 토큰 값을 꺼내 갈 수 없다. 다만 XSS 자체를 막는 것이 아니라, 토큰을 가져가는 경로 하나를 막을 뿐이다.
그렇다면 쿠키면 충분할까?
쿠키는 브라우저가 요청마다 자동으로 실어 보낸다. 그래서 다른 사이트가 사용자 브라우저로 우리 서버에 요청을 보내게 만들면 쿠키도 함께 실리고, 이것이 CSRF다.
따라서 쿠키에 담을 때는 다음 속성을 함께 고려한다.
HttpOnly → JS로 읽지 못하게 한다
Secure → HTTPS 요청에만 보낸다
SameSite → 다른 사이트에서 시작된 요청에 실을지 정한다
Path → 쿠키를 보낼 URL 경로 범위를 줄인다
SameSite는 세 가지 값을 가진다.
Strict: 같은 사이트에서 시작된 요청에만 보낸다.Lax: 다른 사이트에서 링크를 눌러 들어오는 이동(GET)에는 보내고, 다른 사이트에서 보낸 POST 같은 요청에는 보내지 않는다.None: 모든 요청에 보낸다.Secure와 함께 써야 한다.
일부 브라우저는 SameSite를 지정하지 않으면 Lax로 취급한다. 다만 브라우저마다 다르므로 명시하는 편이 안전하다.
중요한 점은 SameSite가 CSRF를 완전히 막지는 못한다는 것이다. 예를 들어 GET 요청으로 상태를 바꾸는 API가 있으면 Lax로도 막히지 않는다.
그래서 OWASP CSRF Prevention Cheat Sheet는 SameSite를 보조 수단으로 보고, GET 엔드포인트가 서버 상태를 바꾸지 않는지 점검하라고 권고한다.
Path도 비슷하다. 쿠키가 실리는 범위를 줄여 주지만 보안 경계는 아니다. (MDN)
저장 위치 조합
토큰은 두 개이므로 실제로는 "어디에 둘까"보다 "어떻게 조합할까"를 고르게 된다. 참고한 글(olrlobt, Access Token, Refresh Token의 저장 위치에 대한 고찰)은 세 가지 조합을 비교한다.
앞에서 세운 XSS, CSRF, 서버 통제, 구현 비용 순서로 각 조합을 본다.
조합 1. Access Token 메모리 + Refresh Token HttpOnly 쿠키 + 서버 확인
- XSS: Refresh Token은 JS로 읽을 수 없다. Access Token은 메모리에 있어 XSS가 실행되는 동안에는 노출될 수 있다.
- CSRF: 쿠키는 Refresh Token뿐이라, CSRF로 노릴 수 있는 요청이 재발급 요청으로 좁혀진다.
- 서버 통제: 서버에도 Refresh Token을 두므로 이상 활동이 보이면 강제로 만료시킬 수 있다.
- 구현 비용: 새로고침하면 메모리의 Access Token이 사라지므로, 페이지 로드 때 재발급을 호출하는 로직이 필요하다. 재발급마다 서버 조회도 더해진다.
원문이 직접 밝힌 것은 서버 통제(강제 만료)이고, XSS, CSRF, 구현 비용 항목은 원문 설명에서 따라 나오는 부분을 정리했다.
조합 2. Access Token, Refresh Token 모두 HttpOnly 쿠키
- XSS: 두 토큰 모두 JS로 읽을 수 없다.
- CSRF: Access Token이 모든 요청에 자동으로 실리므로, 모든 API가 CSRF 대상이 된다.
- 서버 통제: 원문은 이 방식에서도 Refresh Token 추적을 위해 서버 관리가 필요하다고 본다.
- 구현 비용: 헤더에 토큰을 넣는 코드가 필요 없다. 대신 모든 요청에 두 토큰이 실려 요청이 커지고, 쿠키 크기 제한도 고려해야 한다.
조합 1의 쿠키를 늘린 대가가 CSRF 노출 범위의 확대인 셈이다.
조합 3. Access Token 메모리 또는 쿠키 + Refresh Token 서버에만 저장
- XSS: Refresh Token이 브라우저에 없으므로 브라우저에서 Refresh Token을 꺼내 갈 경로가 줄어든다.
- CSRF: Access Token을 메모리에 두면 조합 1과 비슷하지만, 쿠키에 두면 조합 2처럼 모든 요청이 CSRF 대상이 된다.
- 서버 통제: 서버가 Refresh Token을 쥐고 있어 통제가 가장 쉽다.
- 구현 비용: Access Token이 만료될 때마다 서버 저장소를 조회해야 해 응답이 느려질 수 있다. 사용자가 몰리면 갱신 요청이 서버 부하로 이어진다.
즉, Refresh Token을 브라우저에서 뺄수록 탈취 경로는 줄지만 서버가 짊어지는 비용이 커진다.
Ticket Run에 적용한 방식
Ticket Run은 조합 1을 골랐다. Access Token은 재발급 API의 응답 body로 내려 프론트엔드 메모리에 두고, Refresh Token은 쿠키로 내린다.
쿠키는 다음과 같이 만든다.
public class RefreshTokenCookieProvider {
public static final String COOKIE_NAME = "refresh_token";
private static final String PATH = "/api/auth";
private static final String SAME_SITE = "Lax";
private ResponseCookie.ResponseCookieBuilder baseCookie(String value) {
return ResponseCookie.from(COOKIE_NAME, value)
.httpOnly(true)
.secure(true)
.sameSite(SAME_SITE)
.path(PATH);
}
}
httpOnly로 XSS에서 값을 숨기고, secure로 HTTPS에서만 보낸다. path를 /api/auth로 좁혀 재발급과 로그아웃 요청에만 쿠키가 실리게 했다.
서버는 Refresh Token을 Redis에 회원 ID를 키로 저장하고, 재발급할 때마다 새 토큰으로 바꾸는 토큰 회전(Refresh Token Rotation, RTR)을 적용했다. 이미 바뀐 이전 토큰이 다시 들어오면 저장된 토큰까지 지워 재로그인을 요구한다.
RFC 9700도 공개 클라이언트(public client)의 Refresh Token에는 rotation 또는 sender-constrained 방식 중 하나를 쓰도록 요구한다. (RFC 9700, Section 4.14.2)
다만 CSRF는 아직 SameSite에 기대고 있다. 현재 Spring Security의 CSRF 보호는 꺼져 있고, 쿠키가 실리는 재발급과 로그아웃은 POST이므로 Lax가 다른 사이트의 요청을 걸러 준다.
OWASP가 SameSite를 보조 수단으로 보는 만큼, Origin 검증 같은 방어를 더할지는 고민이 남는다.
정리
세 조합은 모두 Refresh Token을 서버가 통제할 수 있게 한다는 공통점이 있다. 차이는 브라우저에 무엇을 얼마나 두느냐에 있다.
| 조합 | Access Token |
Refresh Token |
XSS | CSRF | 비용 |
|---|---|---|---|---|---|
| 1 | 메모리 | HttpOnly 쿠키 + 서버 |
Refresh Token 값 보호 |
재발급 요청만 노출 | 새로고침 시 재발급 |
| 2 | HttpOnly 쿠키 |
HttpOnly 쿠키 + 서버 |
두 토큰 값 보호 | 모든 요청 노출 | 요청 크기, 쿠키 크기 |
| 3 | 메모리 또는 쿠키 | 서버만 | 브라우저에 Refresh Token 없음 |
Access Token 위치에 따라 다름 |
갱신마다 조회, 부하 |
CSRF 노출을 줄이고 싶다 → Refresh Token만 쿠키에 둔다 (조합 1)
구현을 단순하게 하고 싶다 → 모두 쿠키에 두고 CSRF 방어를 더한다 (조합 2)
통제가 가장 중요하다 → Refresh Token을 서버에만 둔다 (조합 3)
원문의 결론처럼 완벽한 저장 위치는 없다. 어떤 조합을 고르든 그 조합의 약점을 CSRF 방어, 이상 행동 감지 같은 추가 조치로 메우는 것이 핵심이다.