JWT 돌아보기
세션, 쿠키, 토큰의 인가 방식 중에 우리는 왜 굳이 토큰을 사용할까? 라고 물어보면 누구나 쿠키보다 보안이 뛰어나고 세션보다 서버 부하가 적다는 대답을 할 수 있다. 그럼, 왜 쿠키보다는 보안이 뛰어나고 세션보다는 서버 부하가 적을까? 이유를 알아야 '보안이 좀 더 낫고, 서버 과부하가 안와요'라고만 대답해도 찰떡같이 알아들을 수 있다. 이번 면접에서 JWT와 관련한 질문을 받았고, 해당 내용을 까먹어서 제대로 답변하지 못했다. 아쉬운 부분이다.
JWT의 구조
JWT는 위와 같은 세 부분의 구조로 이루어져있다. 중요한 것은 서명, 시그니처 부분이다. JWT가 쿠키에 비해 보안적인 측면에서 좀 더 나은 이유가 된다.
- Header
헤더에는 토큰의 타입과 서명에 적용된 알고리즘이 담겨있다. 형태 예시는 아래와 같다.
{
"typ": "JWT",
"alg": "HS256"
}
해당 토큰은 JWT타입이고, 서명을 생성하는데 적용된 알고리즘은 HMAC SHA256알고리즘이 적용되었다. 별 중요한 내용은 없다.
- Payload
{
"roles": [
"BARBER",
"CUSTOMER"
],
"email": "test@email.com",
"iat": 1708425562,
"exp": 1708426162
}
Payload에는 사용자나 토큰의 정보가 key - value 형태로 담긴다. 위의 예시에는 사용자의 이메일 정보, 토큰의 발급 시간과 만료 시간이 담겨있다. JWT 표준 스펙상 Payload에 담겨야 되는 정보는 아래의 7가지다.
- iss(Issuer) : 토큰의 발급자
- sub(Subject) : 토큰 제목 - 사용자 식별 값
- aud(Audience) : 토큰 대상자
- iat(Issued At) : 토큰 발급 시간
- exp(Expiration Time) : 토큰 만료 시간
- nbf(Not Before) : 토큰 활성 날짜 - 해당 날짜 이전 토큰은 사용할 수 없다.
- jti(JWT ID) : JWT 토큰 식별자 - Issuer가 여럿일 때 중복 처리를 방지하기 위해 사용한다.
물론 위의 7가지 정보가 모두 담길 필요는 없다. 서버에서 재량껏 설정하면 된다. 중요한 점은 Payload에는 중요한 정보를 담으면 안된다는 것이다. JWT의 값은 아주 쉽게 디코딩이 가능하다.
길게 작성된 토큰 값도 이렇게 쉽게 디코딩이 가능하다. 만약 JWT가 탈취되었는데 그 안에 사용자의 비밀번호와 같은 중요한 정보가 담겨있다면 아주 쉽게 해킹에 성공할 수 있다. 따라서 Payload에는 해당 사용자를 식별할 수 있는 정보 정도만 담아야한다. 그런데 하나 디코딩이 안되는 부분이 있다. 바로 서명 부분이다.
- Signature
위의 사진에서 알 수 있듯이, Header와 Payload에 담긴 정보는 쉽게 디코딩해 확인이 가능하다. 그러나 Signature부분은 디코딩이 되지 않은 모습이다. 잘 살펴보면, Header에서 확인할 수 있는 HMAC SHA256알고리즘 안에 무언가 넣은 형태로 Signature는 이루어져 있다.
들어가는 값들을 보면 header값과 payload값을 base64 인코딩한 값과 your-256-bit-secret이라는 값이 들어간다. 즉, JWT를 발급하는 서버에서만 알고 있는 secret-key를 가지고 Signature를 작성한다는 것이다. 이 부분에서 JWT가 쿠키를 사용하는 방식과 차이를 보이며 이를 통해 우리는 JWT가 쿠키를 사용하는 방식보다 더 보안이 뛰어나다고 말할 수 있다.
동시에 Signature가 있기 때문에 JWT는 stateless한 상태를 유지할 수 있는데, 이 토큰이 내가 발급한 토큰인지 알고 싶으면 클라이언트에서 보낸 Signature만 검증하면 되기 때문이다. 굳이 발급한 토큰 정보나 현재 서버와 연결된 사용자 정보를 모두 서버에 보관할 필요가 없다.
보완해야 할 점은?
물론 토큰 자체를 해커에게 탈취당한다면 해당 토큰으로 이것저것 요청을 보낼 수는 있다. 이를 해결하기 위해 우리는 토큰의 유효기간을 짧게 설정하고, 토큰을 재발급 받기 위한 RefreshToken을 사용하는 방식을 선택한다. 당연히 둘 다 탈취당하면 답이 없지만, 토큰만 사용해서 완벽하게 보안대책을 마련하기는 어렵다.
개인적으로 생각나는 보완 방법은 세션 방식과 토큰을 동시에 사용하는 것인데, RefreshToken에 요청을 보낸 사용자의 IP값을 담아서 보내주는 것이다. 별도의 DB에 인증한 사용자 IP값과 인증 시간을 저장하고, RefreshToken이 들어왔을 때 지금 요청을 보낸 사용자의 IP값과 DB에 저장된 IP를 먼저 대조하고, 만일 현재 해당 IP값이 검색되면, 해당 IP에서 인증 요청을 보냈었던 시간을 다시 한 번 검증해 두 시간이 같은지를 확인하는 형태로 말이다.
JWT vs COOKIE
사실 Header와 Payload만 사용한다면 JWT는 쿠키와 다를 바가 없다. 쉽게 디코딩이 가능한 정보들을 그냥 토큰값으로 바꿔 사용할 뿐이다. 그러나, 우리에게는 Signature가 존재한다. Signature에는 서버만 알고 있는 정보가 담겨있다. 즉, 서버에서는 Signature에 담긴 정보를 가지고 이게 우리가 발급한 토큰이 맞는지를 확인할 수 있다. 예를 들어, 해커가 이렇게 저렇게 Header와 Payload를 작성해서 내 서버에 가짜 요청을 보내고 싶어도, 서버가 사용하는 secret key를 모르면 Signature를 작성할 수 없다.
JWT vs SESSION
앞서 언급한대로, 서버 입장에서는 Signature덕분에 발급한 JWT를 굳이 서버 내에 저장할 필요가 없다. 세션을 사용한다면, 요청이 올 때마다 해당 요청을 보낸 사용자가 서버에 인증된 사용자인지 세션 DB 또는 세션 서버를 찔러봐야 하지만, JWT를 사용한다면 요청에 담겨온 토큰이 내가 발급한 토큰이 맞는지만 검증하면 된다.
사용 예시
그래서 JWT가 지닌 장점이 Signature에서 온다는 것은 알았다. 그럼 나는 이걸 어떻게 사용하고 있었을까. 세부적인 구현은 링크에서 확인할 수 있다. 다만, Signature를 위한 key값을 보관하는 부분만 살펴보자.
@Getter
@Component
public class JwtManager {
@Value("${jwt.key}")
private String secretKey;
@Value("${jwt.access-token-expiration-minutes}")
private int accessTokenExpirationMinutes;
@Value("${jwt.refresh-token-expiration-minutes}")
private int refreshTokenExpirationMinutes;
...
}
Signature의 핵심 재료인 secret key는 설정 파일에 저장해놓았다. 설정 파일에 저장된 값을 읽어서 사용하면, 해당 값을 숨길 수 있기 때문이다.
설정 파일에 담긴 key값은 다시 한 번 시스템 환경 변수에 숨어있다. 따라서 외부에서 서버 컴퓨터를 해킹해서 뜯어보지 않는 이상은 Signature를 만들기 위한 key값을 알 수가 없다.
정리
아니 분명히 이렇게 다 이유를 가지고 만들었었는데, 왜 정작 면접 때는 절어서 말을 못했을까... 그야 당연히 이걸 왜 이렇게 해놨었는지 기록을 안해놨으니까지. 면접 전에 나름대로 블로깅 했던 내용들을 한 번 복습하는데, 이 내용을 안써놨으니까 당연히 기억을 못 할 수밖에, 심지어 이 부분은 구현한지도 꽤 돼서 진짜 완전히 까먹었다.
아무튼 JWT가 지닌 이점의 핵심은 모두 Signature에서 나온다는 것을 다시 한 번 확인하면서, 오늘의 포스팅을 마친다.