
그동안 JWT를 어디에 저장했지?
아마도 로컬스토리지, 쿠키, 메모리 셋 중 하나일 것이다. 어딘가 저장하기로 정했다면, 왜 그곳에 저장하려고 했는지 누군가 묻는다면 대답할 수 있는가? 🤣
JWT는 Stateless한 구조를 갖기 때문에 서버에서는 토큰만 유효하다면 해당 토큰을 보낸 사용자의 요청 또한 유효하다고 판단한다. 즉 토큰이 탈취된다면 해당 사용자로서 할 수 있는 모든 행위를 탈취자가 할 수 있게 된다.
저장소를 고를 때 고려해야 할 주요 공격 유형은 XSS(Cross Site Scripting)와 CSRF(Cross Site Request Forgery)이다.
사실 다른 것도 고려되면 더 좋다. 웹 개발에서 XSS와 CSRF는 가장 대표적이고 빈번하게 발생하는 공격 유형이므로 최소한의 고려 사항이다.
간단하게 말하자면 클라이언트에서 공격자가 심어둔 스크립트가 실행되는 것이다. 주로 사용자의 권한 및 토큰 탈취를 목적으로 진행되고 사용자가 특정 사이트를 신뢰한다는 사실을 이용한 공격 방식이다. 만약 스크립트로 접근할 수 있는 곳에 토큰이 저장되어 있다면 XSS 공격에 취약한 것이다. 다행히도 HTML5에서 innerHTML을 통한 코드는 실행되진 않지만, 어떠한 방식이든 스크립트가 실행된다면 로컬스토리지나 httpOnly 설정이 되어있지 않은 쿠키에 저장된 정보들은 전부 탈취된다.
CSRF는 인증된 사용자가 자신의 의지와는 무관하게 특정 요청을 보내도록 하는 공격 방식이다. 토큰을 탈취하지 않더라도 쿠키에 저장된 토큰은 해당 사용자가 요청을 보낼 때 자동으로 담겨져 있기 때문에 서버에서는 정상적인 요청과 비정상적인 요청을 구분할 수 없다. CSRF는 사용자 정보 탈취의 목적보다는 특정 작업을 무단으로 진행하기 위한 목적으로 이루어진다.
둘은 사용자의 브라우저를 대상으로 한다는 공통점이 있다. XSS는 사용자가 특정 사이트를 신뢰한다는 사실을 이용한 공격 방식 이지만, CSRF는 웹 어플리케이션이 인증된 사용자의 요청을 신뢰한다는 사실을 이용한 공격 방식이다. CSRF는 토큰을 탈취하지 않아도 해당 세션에서 요청만 보내면 되는 것이다.
스크립트 한 줄이면 로컬 스토리지에 있는 토큰을 탈취할 수 있다.
→ XSS에 취약하다.
httpOnly 플래그를 통해 스크립트로 쿠키를 읽지 못하게 할 수 있다. 다만 스크립트로 쿠키에 있는 토큰을 탈취하지 못하더라도 같은 사이트에서 요청을 어떻게든 보내면, 공격자가 요청 url을 안다면, 요청이 위조될 수 있다.
→ CSRF에 취약하다
토큰을 private variable에 저장한다. 외부에서 접근하기 어렵다. 쿠키 때문에 요청에 자동으로 담기는 일도 없다. 새로고침할 때 마다 사라진다.
→ 비교적 안전하지만, UX 개선 필요
메모리에 JWT토큰을 저장한다고 했을 때 발생하는 UX 문제를 해결하기 위해서 필요한 토큰이다. Refresh Token을 통해서 사용자는 Access Token을 재발급 받을 수 있으면 사용자는 새로고침 할 때마다 로그인할 필요가 없다. 사용자가 다시 로그인 해야 할 시기는 Refresh Token이 만료될 시기다.
Refresh Token의 핵심은 받아온 Access Token은 변수에 저장해서 혹시 Refresh Token이 탈취 혹은 요청이 위조되더라도 공격자가 Access Token을 사용할 수 없다는 것에 있다.
Refresh Token은 쿠키에 저장하고 httpOnly, secure=true, sameSite=strict 플래그를 사용해 CSRF를 최대한 방지하긴 해야 한다.
Access Token과 Refresh Token을 전부 로컬 스토리지 혹은 쿠키에 저장하는건, 그냥 token 한개만 쓰는거랑 똑같다.
프론트엔드에서 AccessToken과 RefreshToken을 관리하는 방법
const RedirectPage = () => {
// Get Access Token from Redirect Url Query Params
const location = useLocation();
const navigate = useNavigate();
const queryParmas = new URLSearchParams(location.search);
const accessToken = queryParmas.get('accessToken');
useEffect(() => {
axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
// Login API request, Context dispatch
navigate(HOME);
}, [accessToken]);
return <div />;
};
axiosInstance.interceptors.response.use(
(error) => {
if (error.response) {
if (error.response.status === 401) {
refreshToken();
return {
code: '401',
message: '401',
};
}
}
return Promise.reject(error);
}
);
잘 보고 갑니당