이 글은 아래 원문을 번역한 글로 의역이 있을 수 있습니다. 정확한 의미를 파악하고 싶으신 분은 원문을 참고해주시기 바랍니다.
원문: https://indepth.dev/posts/1382/localstorage-vs-cookies
저작권 정보: https://indepth.dev/
제 이전 글에서는 OAuth 2.0이 어떻게 동작하는지 살펴보고 액세스 토큰과 리프레시 토큰을 생성하는 방법을 알아보았습니다. 이제 토큰을 프런트 엔드에 저장하는 방법에 대해 살펴보겠습니다.
액세스 토큰은 일반적으로 서버가 서명한 것으로 만료 기한이 짧은 JWT 토큰이고, 요청에 권한을 부여하기 위해 서버로 보내는 모든 HTTP 요청에 포함됩니다. 리프레시 토큰은 일반적으로 DB에 저장되어 있으며 만료 기한이 긴 이해하기 어려운 문자열이고, 액세스 토큰이 만료될 때 새로운 액세스 토큰을 가져오기 위해 사용됩니다.
토큰은 프런트 엔드의 어디에 보관하면 될까요?
토큰을 저장하는 두 가지 일반적인 방법이 있습니다. 첫 번째는 로컬 스토리지에 저장하는 것이고, 두 번째는 쿠키에 저장하는 것입니다. 쿠키가 좀 더 안전하기 때문에 대부분의 사람들이 쿠키 쪽으로 기우는 가운데 어느 것이 더 나은지에 대한 많은 논쟁이 있습니다.
로컬 스토리지와 쿠키를 비교해보겠습니다. 이 글은 주로 이 게시물과 이 게시물에 달린 댓글을 바탕으로 작성되었습니다.
로컬 스토리지
장점: 편리하다.
- 순수 JavaScript이고 편리합니다. 백엔드가 없고 타사 API에 의존하는 경우, 매번 타사 API에 당신의 사이트를 위해 사용자에게 특정 쿠키를 설정하도록 요청할 수 없습니다.
- 액세스 토큰을 헤더에 넣어주어야 하는 API에 사용할 수 있습니다. ('Authorization Bearer ${access_token}')
단점: XSS 공격에 취약하다.
XSS 공격은 공격자가 웹 사이트에서 JavaScript를 실행할 수 있을 때 발생합니다. 즉, 공격자는 로컬 스토리지에 저장된 액세스 토큰을 가져올 수 있습니다. XSS 공격은 React, Vue, jQuery, Google Analytics 등과 같이 웹 사이트에 포함된 타사 JavaScript 코드에서 발생할 수 있습니다. 웹 사이트에 타사 라이브러리를 포함하지 않는 것은 거의 불가능합니다.
httpOnly 쿠키
장점: 쿠키는 JavaScript로 접근할 수 없기 때문에, 로컬 스토리지만큼 XSS 공격에 취약하지 않습니다.
- httpOnly와 보안 쿠키를 사용하면 쿠키에는 JavaScript로 접근할 수 없고, 이는 공격자가 웹사이트에서 JavaScript를 실행할 수 있어도 쿠키에서 액세스 토큰을 읽을 수 없음을 의미합니다.
- 서버로 보내는 모든 HTTP 요청에 자동으로 전송됩니다.
단점: 경우에 따라 토큰을 쿠키에 저장하지 못할 수도 있습니다.
- 쿠키의 크기는 4KB로 제한됩니다. 그러므로 큰 JWT 토큰을 사용한다면, 쿠키에는 저장할 수 없습니다.
- API 서버와 쿠키를 공유할 수 없거나, authorization header에 액세스 토큰을 넣어주어야 하는 API인 경우가 있습니다. 이 경우 토큰을 쿠키에 저장할 수 없습니다.
XSS 공격
로컬 스토리지는 JavaScript를 사용하여 쉽게 접근할 수 있고, 공격자가 액세스 토큰을 얻어서 나중에 사용할 수 있기 때문이 취약합니다. 반면에, httpOnly 쿠키는 JavaScript를 사용하여 접근할 수 없지만, 쿠키를 사용한다고 해서 액세스 토큰과 관련된 XSS 공격으로부터 안전하다는 의미는 아닙니다.
공격자가 응용프로그램에서 JavaScript를 실행할 수 있으면 자동으로 쿠키를 포함해서 서버로 HTTP 요청을 보낼 수 있습니다. (읽을 필요가 거의 없지만) 공격자는 토큰의 내용을 읽지 못해 조금 덜 편리할 뿐입니다. 어쩌면 공격자의 기계를 사용하지 않고 (HTTP 요청을 보내는 것만으로) 피해자의 브라우저를 사용하여 공격할 수 있으므로 공격자에게 유리할 수도 있습니다.
쿠키와 CSRF 공격
CSRF 공격은 사용자가 의도하지 않은 요청을 수행하도록 강제하는 공격입니다. 예를 들어, 웹 사이트가 아래와 같이 이메일 변경 요청을 수락하는 경우:
Host: site.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 50
Cookie: session=abcdefghijklmnopqrstu
email=myemail.example.com
공격자는 악의적인 웹 사이트에서 숨겨진 전자 메일 필드와 함께 https://site.com/email/change로 POST 요청을 날리고 세션 쿠키는 자동으로 포함되는 양식을 쉽게 만들 수 있습니다.
그러나, 쿠키에 있는 sameSite 플래그를 사용하고, anti-CSRF 토큰을 포함하면 이 문제는 쉽게 완화할 수 있습니다.
로컬 스토리지보다 쿠키를 선호
쿠키는 여전히 취약한 면이 있지만, 로컬 스토리지보다는 좋습니다. 왜일까요?
- 로컬 스토리지와 쿠키 모두 XSS 공격에 취약하지만, httpOnly 쿠키를 사용하면 공격자가 공격하기 더 어렵습니다.
- 쿠키는 CSRF 공격에 취약하지만, sameSite 플래그와 anti-CSRF 토큰을 사용하여 완화할 수 있습니다.
- Bearer header나 4KB 보다 큰 JWT 토큰을 사용하여 인증을 하는 경우에도 계속 사용할 수 있습니다.
이는 OWASP 커뮤니티의 권고사항과도 일치합니다:
JavaScript를 통해 접근할 수 있으므로 세션 식별자를 로컬 스토리지에 저장하지 마십시오. 쿠키는 httpOnly 플래그를 사용하여 이 위험을 완화할 수 있습니다.
- OWASP: HTML5 보안 치트 시트
그럼, OAuth 2.0 토큰을 유지하려면 쿠키를 어떻게 사용해야 할까요?
요약하자면, 토큰을 저장할 수 있는 방법은 다음과 같습니다:
- 1번: 액세스 토큰을 로컬 스토리지에 저장(리프레시 토큰도 로컬 스토리지나 httpOnly 쿠키에 저장): 액세스 토큰은 XSS 공격으로 도난당할 수 있습니다.
- 2번: 액세스 토큰과 리프레시 토큰을 httpOnly 쿠키에 저장: CSRF에 취약하지만 완화할 수 있고, XSS 공격면에서는 조금 더 낫습니다.
- 3번: 리프레시 토큰을 httpOnly 쿠키에 저장: CSRF에서 안전하고, XSS 공격면에서 조금 더 낫습니다.
3번이 가장 좋은 방법이기 때문에, 우리는 3번이 어떻게 작동하는지 살펴보겠습니다.
액세스 토큰을 메모리에 저장하고, 리프레시 토큰을 쿠키에 저장:
액세스 토큰을 메모리에 저장한다는 것은 로컬 스토리지나 쿠키에 넣는 대신 변수에 넣는다는 것입니다(const accessToken = XYZ처럼요).
왜 CSRF로부터 안전할까요?
/refresh_token으로 양식을 보내고 새로운 액세스 토큰이 반환되지만, 공격자가 HTML 양식을 사용한다면 이 응답을 읽을 수 없습니다. 공격자가 성공적으로 요청하고 응답을 읽는 것을 막으려면, 인증되지 않은 웹 사이트의 요청을 막기 위해 인가 서버의 CORS 정책이 올바르게 설정되어야 합니다.
그럼 이건 어떻게 동작할까요?
1단계: 사용자가 인증할 때 액세스 토큰과 리프레시 토큰을 반환합니다.
사용자가 인증되면 인가 서버는 액세스 토큰과 리프레시 토큰을 반환합니다. 액세스 토큰은 response body에 포함되고, 리프레시 토큰은 쿠키에 포함됩니다.
리프레시 토큰 쿠키 설정 시 유의 사항:
- JavaScript로 읽는 것을 방지하기 위해 httpOnly 플래그를 사용합니다.
- HTTPS로만 보내질 수 있도록 secure=true 플래그를 사용합니다.
- CSRF를 방지하기 위해 가능하면 SameSite=strict 플래그를 사용합니다. 이 방법은 인가 서버가 프런트 엔드의 도메인이 같은 경우에만 사용할 수 있습니다. 그렇지 않은 경우에는 인가 서버에서 CORS 헤더를 설정하거나, 리프레시 토큰 요청은 인증된 웹 사이트에서만 할 수 있도록 보장하는 다른 방법을 사용해야 합니다.
2단계: 액세스 토큰을 메모리에 저장합니다.
토큰을 메모리에 저장한다는 것은 액세스 토큰을 프런트 엔드 변수로 저장한다는 의미입니다(ex. const accessToken = xyz). 이는 사용자가 탭을 바꾸거나 사이트를 새로고침 하면 액세스 토큰이 사라지는 것입니다. 그래서 리프레시 토큰이 필요합니다. 액세스 토큰을 JavaScript로 로컬 스토리지나 쿠키에 넣지 않는 이유는, 그렇게 하면 공격자가 데이터를 더 쉽게 복사할 수 있어 XSS 공격으로 데이터를 도난당하기 쉽기 때문입니다.
3단계: 리프레시 토큰을 이용하여 액세스 토큰을 갱신합니다.
액세스 토큰이 사라졌거나 만료된 경우, /refresh_token API를 호출하면, 1단계에서 쿠키에 저장된 리프레시 토큰이 요청에 포함됩니다. 그러면 새로운 액세스 토큰을 받아 API 요청에 사용할 수 있습니다. 이렇게 하면 JWT 토큰이 4KB보다 클 수 있고, Authorization header에 넣을 수도 있습니다.
끝입니다!
기본적인 내용을 다뤘어요. 당신의 사이트를 보호하는데 도움이 될 거예요.
참고 자료
이 글을 작성하면서 몇 가지 글을 참조했습니다.
- Please Stop Using Local Storage
- The Ultimate Guide to handling JWTs on front-end clients (GraphQL)
- Cookies vs Localstorage for sessions — everything you need to know
질문 & 피드백
궁금증이 있거나 피드백이 필요하다면, 부담 갖지 말고 댓글로 남겨주세요!