본 포스팅에서는 프로젝트에서 채택한 JWT 를 통한 토큰 교환 방식에 대한 생각을 적어보려고 합니다.

설명하기 앞서, 간단히 JWT에 대해 알아보겠습니다.

JWT 란?

  • header

    • typ : JWT (토큰의 종류를 지정. 이 경우 JWT로 고정)
    • alg : 암호화 알고리즘
  • payload

    • 토큰에서 사용할 정보들의 조각인, Claim 이 담겨 있다.
    • 클레임은 JSON(Key, Value)의 형태로, 다수의 정보를 넣을 수 있다.
  • Signature (서명)

    • 서명은 토큰의 유효성 검증을 할 때 사용되는 곳으로, 무결성을 보장한다.

    • 헤더 + 페이로드를 BASE64로 인코딩 한 값을 지정된 비밀 키를 이용해 헤더에 적혀있는 alg 으로 암호화한다.

서명은 데이터를 암호화 하는 것은 아니고, 토큰의 데이터가 변조 되지 않았다는 것을 보장합니다. -> 무결성

해커가 페이로드의 값을 조금이라도 바꾼다면 해시 알고리즘에 의해 Signature는 바뀔 것이고, 서버에서 해당 값을 비교해 데이터가 변조되었는지, 아닌지를 알 수 있습니다.



JWT 생성 방식

다음은 프로젝트에서 엑세스 토큰을 생성하는 JwtUtil 클래스의 일부입니다.

스크린샷 2024-02-02 오전 8 36 07
스크린샷 2024-02-02 오전 8 36 27
스크린샷 2024-02-02 오전 8 37 17

Secret-Key는 서버에서만 보관하여, 대칭키 암호화 방식을 통해 서명을 생성합니다.

엑세스 토큰의 Claim에는 멤버 email, provider, refreshTokenId를 넣었습니다.

리프레시 토큰을 생성하는 방식도 크게 다르지 않습니다.
스크린샷 2024-02-02 오전 8 38 15

리프레시 토큰의 claim 에는 memberEmail 만을 넣었습니다.

현재 토큰 교환 로직은 다음과 같습니다.

인증에 성공하면 Access Token, Refresh Token을 발급한다. DB 에 Refresh Token을 저장해두고, Access Token만을 클라이언트에게 제공한다. 엑세스 토큰이 만료된 경우, 토큰의 클레임에 들어있는 refreshTokenId 값으로 DB에서 refresh token을 조회합니다.

해당 id를 가지는 refresh token이 여전히 유효하다면 access token을 재발급하고, 유효하지 않다면 403 에러를 반환합니다.

리프레시 토큰이 엑세스 토큰 발급하는 역할을 하는 것입니다.



그렇다면 왜 엑세스 토큰만을 제공하고, 리프레시 토큰은 DB에만 저장하는지?

JWT로 발급한 엑세스 토큰에는 민감 정보를 담지 말라는 것이 원칙입니다. 이는 해커가 토큰을 탈취해 사용할 수 있기 때문이죠.

따라서 엑세스 토큰이 탈취되면 사실 손 쓸 방법은 없습니다. 제한시간을 짧게 두어 피해를 줄이는 것이 최선이라고 합니다.

JWT 방식은 XSS, CSRF 등과 같은 요청을 강제로 발생시키는 공격에 여전히 취약하기도 하며, 따라서 보안을 위해서 이와 같은 방식을 선택했습니다.


보안 공격과 관련된 간단한 사례를 들어보겠습니다.

CSRF

사용자 브라우저에 AAA.com 의 인증 정보인, 엑세스 토큰이 쿠키로 저장되어 있습니다.

이 때 BBB.com 이라는 악성 웹사이트가 img 태그나 하이퍼링크를 통해 서버에 DELETE /userInfo 와 같은 요청을 한다면, 이 요청은 브라우저의 인증 쿠키가 포함된 채로 서버로 전송되고, 서버는 이를 정상적인 요청으로 간주하여 사용자 정보를 삭제해버립니다.


XSS

AAA.com의 게시판에 해커가 브라우저의 쿠키를 참조하여 자동으로 서버에 요청을 보내는 script 태그를 심어놓았습니다. 웹 페이지가 렌더링 되자마자 해당 코드가 작동해 클라이언트 모르게 서버로 요청이 전송됩니다.


사실 요즘은 위 두 가지 경우는 대부분의 사이트에서 방어가 되어 있긴 합니다.

CSRF의 경우 referrer 검사, csrf 토큰 검증, double submit 쿠키 검증과 같은 방식으로 방어할 수 있습니다.

XSS의 경우에는 클라이언트 차원에서 태그 입력 방지(이스케이프 문자열) 같은 방법으로 충분히 대응할 수 있습니다.

하지만 위와 같은 방식이 100% 방어한다는 보장이 없기 때문에, 해당 공격에 노출되었다는 가정하에 위조된 요청을 어떻게 방어할지에 대한 고민도 필요하다는 생각이 들었습니다.



토큰 교환 전략


1. Refresh Token은 쿠키에, Access Token은 Response Body로 전달한다.

HttpOnly 쿠키를 활용해 JavaScript 코드가 침투하는 것을 막을 수 있습니다.

리프레시 토큰을 HTTPOnly 쿠키로 전달한다면, Client-Side JavaScript 가 이 쿠키에 접근할 수 없습니다. 이렇게 하면 XSS 공격을 방지할 수 있습니다. 클라이언트 단에서 로컬 혹은 세션 storage에 값을 저장할 일도 없어, 더 안전하다고 볼 수 있습니다.


이러한 이유들로 인해 어플리케이션에서 Refresh Token을 쿠키에 넣는 경우가 많다고 합니다.

따라서 이 방식은 서버에서 refresh token을 따로 저장하지 않고도, 토큰의 유효성 검사를 쉽게 할 수 있게 됩니다.


단점으로는 쿠키는 크로스 도메인 제한이 있기 때문에 관리가 복잡해질 수 있고, 쿠키의 크기가 아주 큰 경우에는 네트워크 트래픽 낭비를 야기할 수도 있습니다.



2. Refresh Token은 DB에 저장, Access Token만 Response Body 에 전달한다.


이것이 저희 프로젝트에서 채택한 방법입니다.

리프레시 토큰을 DB단에 저장하고 클라이언트는 Bearer 방식으로 엑세스 토큰을 전달해줍니다.


엑세스 토큰의 유효기간이 만료되면 Refresh Token 이 유효하다면, 엑세스 토큰을 재발급해줍니다. 클라이언트는 refresh token을 가지고 있지 않습니다.

하지만 개발 후에, 해당 방식이 바람직한지에 대한 의문이 들었습니다.


프론트는 엑세스 토큰만 가지고 만료된 엑토큰으로 서버에 접근시, 서버 DB에서 저장된 리프레시 토큰으로 엑세스 토큰을 재발급한다.

-> ????

리프레시 토큰을 서버에만 저장하고 프론트에는 주지 않는다면, 그건 엑세스 토큰을 발급해주는 자판기에 불과하다는 생각이 들었습니다.


따라서 이 방식은 적절하지 않다고 생각이 바뀌었습니다.

한 번 엑세스 토큰이 탈취되면, 해커가 계속해서 엑세스 토큰을 재발급 받을 수 있으니까요. 이는 큰 문제입니다.


따라서 두 토큰 모두 클라이언트에게 발급해 주고, 클라이언트가 유효한 Access Token, Refresh Token을 서버에 제시해야 한다는 생각이 들었습니다.

프론트에서 리프레시 토큰을 제시해주어야 내가 누구인지, 권한이 있는지를 증명할 수 있을 것입니다.



결론


따라서 1번 + 2번 방식을 모두 채택하는 것이 더 나은 방법이라고 생각합니다.

RefreshToken은 쿠키에, AccessToken은 응답으로 제공하고 거기에 DB에 리프레시 토큰을 저장해서 유효성 검사를 하는 것입니다.

DB에 리프레시 토큰을 저장하면 문제 발생 시 DB에 있는 리프레시 토큰을 지워서 대처하는 등 서버쪽에서 능동적으로 대처할 수 있는 일이 많을 것이라 생각합니다.


또한 Refresh Token이 사용될 때마다 새로운 access token과 함께 refresh token을 발급하여 이전에 발급된 토큰들을 사용 불가능하게 바꾸는 방법을 추가해도 좋을 것 같습니다. RTR(Refresh Token Rotation)


사실상 완벽한 보안은 없습니다. 인증 방식에는 서로 장단점이 존재하며 상황에 맞게 사용해야 될 것 같다는 것을 느꼈습니다.

브라우저와 서버 모두 보안에 신경쓰고 대비를 하는 것이 최선일 것 같습니다.

토큰 교환 전략은 팀원분들과 충분한 회의를 거친 후, 그대로 갈 지 바꿀 지 정할 생각입니다.