OAuth 2.0 / OIDC 리프레시 토큰 보안 저장소 선택 가이드
리프레시 토큰(Refresh Token)은 액세스 토큰보다 수명이 훨씬 길고, 탈취될 경우 공격자가 사용자 세션을 장기간 유지할 수 있다. 그래서 액세스 토큰보다 훨씬 신중하게 다뤄야 한다. 문제는 “어디에 저장할 것인가”를 두고 실무에서 의견이 갈린다는 것이다. localStorage부터 HttpOnly 쿠키, 서버 측 세션 저장소까지 각각 장단점이 다르고, 클라이언트 환경(SPA, 모바일 앱, 백엔드 서버)에 따라 올바른 선택도 달라진다.
이 글에서는 OAuth 2.0과 OIDC를 실제로 구현하면서 리프레시 토큰 저장소를 고민하는 개발자를 위해, 각 저장소의 보안 특성과 실전 적용 기준을 구체적으로 정리해보았다.
리프레시 토큰이 특히 위험한 이유
액세스 토큰은 보통 수 분에서 한 시간 이내로 만료된다. 탈취되더라도 공격자가 사용할 수 있는 시간이 짧다. 반면 리프레시 토큰은 수일에서 수개월 단위로 유효하다. 탈취된 리프레시 토큰 하나로 공격자는 계속해서 새 액세스 토큰을 발급받을 수 있다.
또한 리프레시 토큰은 일반적으로 인가 서버(Authorization Server)와 직접 통신할 때 사용된다. 이 요청에는 client_secret이나 PKCE 검증이 함께 포함될 수 있지만, 공개 클라이언트(Public Client)인 SPA나 모바일 앱에서는 client_secret을 안전하게 보관할 방법이 없다. 그래서 저장 위치 선택이 더 중요해진다.
저장소 옵션별 보안 특성 비교
localStorage / sessionStorage
localStorage는 JavaScript에서 가장 쉽게 접근할 수 있는 저장소이다. 그만큼 XSS(Cross-Site Scripting) 공격에 그대로 노출된다. 페이지에 악성 스크립트가 한 줄이라도 실행되면, 리프레시 토큰은 즉시 유출된다.
sessionStorage는 탭이 닫히면 데이터가 사라지지만, XSS 취약점은 동일하다. 리프레시 토큰처럼 민감한 값을 이 두 저장소에 저장하는 것은 OWASP에서도 권장하지 않는다.
- 사용을 피해야 할 경우: 리프레시 토큰,
client_secret, 민감한 PII 데이터
메모리(In-Memory) 저장
JavaScript 변수나 클로저에 토큰을 저장하는 방식이다. XSS 공격으로 탈취하기가 localStorage보다 어렵고, 페이지를 새로고침하면 토큰이 사라진다.
반면, 탭을 새로고침하거나 브라우저를 재시작하면 사용자가 재로그인해야 한다는 단점이 있으며, 그로인해 사용자 경험(UX)이 나빠질 수 있다. 이를 보완하기 위해 일부 구현에서는 액세스 토큰만 메모리에 두고, 리프레시 토큰은 HttpOnly 쿠키에 저장하는 혼합 전략을 사용하기도 한다.
HttpOnly 쿠키
현재 SPA 환경에서 리프레시 토큰을 저장하는 방법으로 가장 널리 권장되는 방식이다. HttpOnly 속성이 설정된 쿠키는 JavaScript로 읽을 수 없기 때문에 XSS 공격에 대한 직접 노출이 차단된다.
다만 CSRF(Cross-Site Request Forgery) 공격에 대한 별도 방어가 필요하다. SameSite=Strict 또는 SameSite=Lax 속성을 함께 설정하면 대부분의 CSRF 시나리오를 막을 수 있다. Secure 속성도 반드시 함께 사용해 HTTPS 전송을 강제해야 한다.
Set-Cookie: refresh_token=<value>;
HttpOnly;
Secure;
SameSite=Strict;
Path=/auth/token;
Max-Age=2592000
Path를 /auth/token처럼 토큰 갱신 엔드포인트로 제한하면, 쿠키가 불필요한 요청에 딸려 나가는 범위를 줄일 수 있다.
서버 측 세션 저장소 (BFF 패턴)
BFF(Backend For Frontend) 패턴은 SPA와 인가 서버 사이에 얇은 백엔드 레이어를 두는 아키텍처이다. 리프레시 토큰은 백엔드에서만 보관하고, 브라우저에는 세션 ID만 쿠키로 전달한다.
이 방식은 토큰 자체가 브라우저 환경에 전혀 노출되지 않아 보안 수준이 가장 높다. 다만 추가 서버 인프라가 필요하고, 세션 저장소(Redis 등)에 대한 관리 부담이 생긴다.
- 적합한 상황: 금융, 의료처럼 보안 요구사항이 높거나 토큰 자체를 클라이언트에 절대 노출하지 않아야 하는 서비스
모바일 앱의 경우
모바일 앱은 브라우저와 저장소 환경이 다르다.
- iOS: Keychain Services — 앱 샌드박스 내에서 암호화된 저장소 제공
- Android: Keystore System + EncryptedSharedPreferences — 하드웨어 수준의 키 보호 활용
모바일에서는 OS 제공 보안 저장소를 사용하는 것이 기본 원칙이다. SharedPreferences(Android)나 NSUserDefaults(iOS)에 평문으로 저장하는 것은 루팅/탈옥 기기에서 쉽게 노출된다.
저장소 선택 기준 정리
| 클라이언트 환경 | 권장 저장소 | 주요 위협 |
|---|---|---|
| SPA (공개 클라이언트) | HttpOnly 쿠키 + BFF | XSS, CSRF |
| SPA (보안 최우선) | BFF + 서버 측 세션 | XSS |
| 모바일 앱 | iOS Keychain / Android Keystore | 루팅, 리버스 엔지니어링 |
| 백엔드 서버 (기밀 클라이언트) | 암호화된 DB 또는 비밀 관리 서비스 | 서버 침해 |
Refresh Token Rotation과 탐지 전략
저장소를 아무리 잘 선택해도, 토큰이 탈취되는 상황은 막을 수 없습니다. 이를 보완하는 핵심 메커니즘이 Refresh Token Rotation이다.
RFC 6749와 OAuth 2.0 Security Best Current Practice(BCP)에서는 리프레시 토큰을 사용할 때마다 새로운 리프레시 토큰을 발급하고, 이전 토큰을 즉시 무효화할 것을 권장한다. 만약 이미 사용된 리프레시 토큰이 다시 제출된다면, 토큰 탈취를 의심하고 해당 사용자의 모든 세션을 종료하는 전략을 쓸 수 있다.
Auth0, Okta, Keycloak 같은 인가 서버들은 이 기능을 Refresh Token Rotation 또는 Automatic Reuse Detection 이름으로 설정에서 지원한다.
Rotation 구현 시 주의할 점
- 네트워크 오류로 새 리프레시 토큰 응답이 클라이언트에 전달되지 못하면, 사용자가 로그아웃될 수 있다. 이를 위해 짧은 재사용 허용 시간 창(grace period)을 두는 방식도 있닫.
- Rotation이 활성화된 경우, 여러 탭이나 기기에서 동시에 토큰 갱신을 시도하면 충돌이 발생할 수 있다. 클라이언트 측에서 갱신 요청을 직렬화하거나 뮤텍스 패턴을 적용하는 것이 좋다.
HttpOnly 쿠키 방식의 실전 적용
실제 SPA에서 HttpOnly 쿠키로 리프레시 토큰을 관리할 때 자주 생기는 구현 고민을 짚어보겠다.
토큰 갱신 흐름
- 사용자가 로그인하면 인가 서버가 액세스 토큰과 리프레시 토큰을 발급한다.
- 액세스 토큰은 JavaScript 메모리에 보관하고, 리프레시 토큰은 HttpOnly 쿠키로 브라우저에 저장한다.
- 액세스 토큰이 만료되면, 클라이언트는 갱신 엔드포인트(
/auth/token)로 요청을 보낸다(쿠키가 자동으로 함께 전송된다). - 서버는 쿠키에서 리프레시 토큰을 읽어 인가 서버에 전달하고, 새 액세스 토큰을 받아 응답한다.
- Rotation이 활성화된 경우, 새 리프레시 토큰도 다시 HttpOnly 쿠키로 설정한다.
이 흐름에서 브라우저의 JavaScript 코드는 리프레시 토큰 값을 직접 읽거나 쓰지 않는다.
CORS와 쿠키
SPA가 다른 도메인의 API 서버와 통신할 때 쿠키를 함께 보내려면 credentials: 'include' 옵션이 필요하다. 서버 측에서는 Access-Control-Allow-Origin에 와일드카드(*)를 사용할 수 없고, 정확한 출처(origin)를 명시해야 한다. Access-Control-Allow-Credentials: true도 설정해야 한다.
로그아웃 처리
로그아웃 시에는 반드시 두 가지를 함께 처리해야 한다.
- 서버 측에서 리프레시 토큰을 무효화(revoke)한다.
- 쿠키를 만료 처리(Max-Age=0)해서 브라우저에서 삭제한다.
인가 서버의 Token Revocation 엔드포인트(RFC 7009)를 지원한다면, 로그아웃 시 반드시 호출하는 것이 좋다.
백엔드 서버(기밀 클라이언트)의 저장 전략
백엔드 서버는 공개 클라이언트와 달리 client_secret을 안전하게 보관할 수 있다. 그러나 리프레시 토큰도 마찬가지로 안전하게 저장해야 한다.
일반적인 접근은 리프레시 토큰을 데이터베이스에 저장할 때 암호화를 적용하는 것이다. 암호화 키는 코드베이스나 환경 변수에 직접 두지 않고, AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager 같은 비밀 관리 서비스를 활용하는 것이 현실적인 방법이다.
토큰을 DB에 저장할 때는 평문이 아닌 해시(HMAC-SHA256 등)나 암호화된 형태로 저장하는 것이 침해 사고 시 피해를 줄일 수 있다.
자주 놓치는 보안 체크리스트
- 토큰 범위 최소화: 리프레시 토큰 발급 시 필요한 scope만 요청합니다. 불필요하게 넓은 권한을 가진 토큰은 탈취 시 피해가 커진다.
- 절대 유효 기간 설정: Rotation을 사용하더라도 리프레시 토큰에 절대 만료 시간(absolute expiry)을 설정해야 한다. 계속 갱신되더라도 일정 기간 후에는 재인증을 요구하는 것이 안전하다.
- 기기/세션 바인딩: 가능하면 리프레시 토큰을 발급한 기기나 IP 정보와 연결해, 전혀 다른 환경에서의 사용 시 추가 검증을 요구한다.
- 감사 로그: 리프레시 토큰 사용 이력을 로깅해서 비정상 패턴을 탐지할 수 있도록 한다.
- PKCE 적용: 공개 클라이언트에서는 반드시 PKCE를 적용해 인가 코드 탈취 공격을 차단한다.
개발에는 정답이 없다. 여러 상황과 선택지 중에서 명확한 이유를 들어 판단하고 선택하는 것이 개발자의 가장 큰 역량이라는 생각이 든다. 리프레시 토큰 저장소 선택 역시 정답은 하나가 아니다. 클라이언트 유형, 보안 요구 수준, 사용자 경험 허용 범위에 따라 최적의 조합이 달라진다. 다만 어떤 방식을 선택하든, Rotation 메커니즘과 명확한 무효화 전략은 함께 갖추는 것이 기본이라고 생각한다.