JWT 토큰을 front-end에 안전하게 저장하려면 어떻게 해야할까?
LocalStorage와 Cookies의 장단점에 대해서 살펴보자.
Access token은 보통 짧은 수명 주기를 가지고, 서버로부터 승인을 받아 인증이 필요한 모든 HTTP request에 포함된다.
그에 반해 Refresh token은 보통 긴 수명 주기를 가지고, DB에 저장되어 access token이 만료되었을 때 새로운 access token을 발급해주는 토큰이다.
토큰을 저장하기 위한 방법은 일반적으로 두 가지 방식이 존재한다. 첫번째는 localStorage에 저장하는 방식이고, 두번째는 cookies에 저장하는 방식이다. 이 두가지 방식에 대해서 어떤 방식이 더 나은지에 대해 많은 논쟁이 있지만, 대부분의 사람들은 쿠키에 저장하는 방식이 더 안전하다고 이야기한다.
그럼 localStorage와 cookies를 비교해보자.
장점 : 편리하다.
만약 back-end를 가지고 있지 않고 third-party API에 의존하고 있다면, 매번 third-party API에 쿠키를 세팅할 수 없을 것이다.
Local Storage에 저장된 access token은 Authorization Bearer ${access_token}과 같은 형식으로 HTTP header에 넣어서 사용해야 한다.
단점 : XSS 공격에 취약하다.
XSS 공격은 웹사이트에서 공격자가 JavaScript를 실행할 수 있을 때 발생한다. 이는 localStorage에 저장되어 있는 사용자의 access token을 공격자가 탈취할 수 있다는 말이다.
XSS 공격은 React, Vue, jQuery, Google Analytics 등과 같은 웹사이트에 포함된 third-party JavaScript 코드에 의해서 발생할 수 있다.
장점 : 쿠키는 JavaScript로 접근이 불가능하다. 그래서 localStorage만큼 XSS 공격에 취약하지 않다.
만약 공격자가 JavaScript를 사용자의 사이트에서 실행한다고 했을 때, cookie의 httpOnly나 secure 옵션을 사용한다면 사용자의 쿠키는 JavaScript의 접근에 안전해진다.
단점 : 일부 케이스에 대해서는 토큰을 쿠키에 저장하지 못할 수도 있다.
쿠키는 4KB의 size limit을 가진다. 그러므로 만약 4KB가 넘는 데이터를 가지는 JWT 토큰을 사용한다면 쿠키는 적절한 선택지가 아닐 수도 있다.
만약 API 서버가 쿠키를 사용할 수 없거나 API의 요청에서 access 토큰을 authorization header에 넣어줘야 한다면 쿠키를 사용할 수 없을 것이다.
LocalStorage는 JavaScript를 사용해서 쉽게 접근이 가능하기 때문에 취약하지만, httpOnly를 적용한 cookie는 JavaScript로 접근이 불가능하다. 하지만 이것이 XSS 공격에 완전히 안전하다는 소리는 아니다.
만약 공격자가 사용자의 애플리케이션에서 JavaScript를 실행할 수 있을 때, 자동으로 사용자의 쿠키에 포함되는 HTTP 요청을 사용자의 서버로 보낼 수 있다. 이때 공격자는 토큰의 정보를 읽을 수 없지만, 읽을 필요 또한 없기 때문에 크게 문제 될 부분이 없다. 또한 공격자의 컴퓨터를 사용하는 것보다 공격 대상자의 브라우저를 사용하는 것(단순히 HTTP 요청을 보내는 것)이 더 유리할 수도 있다.
CSRF 공격은 유저가 의도하지 않은 요청을 하도록 만드는 공격이다. 예를 들어, 만약 웹사이트가 이메일 변경 요청을 아래와 같이 받는다고 해보자.
POST /email/change HTTP/1.1
Host: site.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 50
Cookie: session=abcdefghijklmnopqrstu email=myemail.example.com
그럼 공격자는 쉽게 악의적인 웹사이트에서 form을 만들 수 있다. 이 form은 POST 요청으로 https://site.com/email/change
로 보내지게 되는데, hidden email 필드와 session cookie가 자동으로 포함되어져서 보내진다.
하지만 쿠키에 sameSite 플래그를 사용하고, anti-CRSF token을 사용하면 이 문제를 쉽게 해결할 수 있다.
cookies가 여전히 취약점이 존재함에도 불구하고 localStorage보다 더 선호되는 이유는 왜일까?
앞의 내용을 다시 한 번 상기시키기 위해서 토큰을 저장하는 방법 3가지에 대해서 살펴보자.
Option 1:
사용자의 access token을 localStorage에 저장하라(refresh token은 localStorage나 httpOnly cookies에 저장하라). access token은 XSS attack으로부터 취약하다.
Option 2:
access token과 refresh token을 httpOnly cookies에 저장하라.
CSRF 공격에는 취약하지만 어느정도 예방 가능하고, XSS의 노출 면에서는 조금 더 낫다.
Option 3:
refresh token을 httpOnly cookie에 저장하라.
CSRF 공격으로부터 안전하고, XSS 노출에 대해서는 조금 더 낫다.
이 세 가지 option 중에서 3번째 옵션이 어떻게 작동하는지 한 번 살펴보자.
사용자의 access token은 memory에 저장하고, refresh token은 cookie에 저장한다.
access token을 메모리에 저장하라는 말의 의미는 localStorage나 cookies에 저장하는 것 대신에 access token을 variable에 넣으라는 말이다. (예를 들어, const accessToken = XYZ)
/refresh-token
에서는 form이 제출되고 새로운 access token을 돌려주지만, 공격자가 HTML form을 사용한다고 하면 response를 읽을 수 없다.
공격자가 성공적으로 fetch 또는 AJAX 요청을 하거나 response를 읽는 것을 방지하려면 Authorization server의 CORS 정책을 인증되지 않은 웹사이트로부터 받지 않게 세팅을 잘 해두어야 한다.
그러면 어떻게 셋업을 해야할까?
Step 1: 유저가 인증할 때 access token과 refresh token을 반환한다.
유저가 인증하고 난 후, Authorization server는 access_token과 refresh_token을 반환한다. access_token은 response body에 포함되고, refresh_token은 cookie에 포함된다.
refresh token cookie setup :
secure=true
플래그를 사용하면 HTTPS에서만 요청을 보낼 수 있다.sameSite=strict
플래그를 사용해서 CSRF를 방지해라. 이 플래그는 Authorization server가 사용자의 프론트엔드와 같은 사이트일 때만 사용할 수 있다. 만약 이 경우가 아니라면 사용자의 Authorization server는 CORS header를 백엔드에서 설정하거나 refresh token 요청을 인증된 웹사이트에서만 완료시킬 수 있도록 세팅해주어야 한다.Step 2: 메모리에 access token을 저장한다.
토큰을 in-memory에 저장한다는 것의 의미는 사용자의 access token을 front-end에서 variable에 넣어두는 것을 뜻한다. (예를 들어 const accessToken = xyz)
만약 유저가 탭을 전환하거나 페이지를 새로고침 한다면 access token은 사라지게 될 것이다. 그래서 바로 refresh token이 필요한 것이다. 공격자가 JavaScript로 데이터를 탈취하기 쉽기 때문에 access token을 localStorage나 cookie에 넣어두지 않는 것이다.
Step 3: refresh token을 이용해서 access token을 갱신한다.
access token이 사라지거나 만료되었을 때, step 1에서와 같이 cookie에 저장한 refresh token을 /refresh-token
endpoint로 보내준다. 그럼 유저는 새로운 access token을 받게 될 것이다. 그리고 그것을 새로운 API 요청에 사용할 수 있다. 이는 유저의 JWT token은 4KB보다 더 클 수 있고, 이것을 Authorization Header에 포함시킬 수도 있다는 것을 의미한다.