Session VS Token Authentication - feat. JWT 기술

Authentication

앱을 만들 때 회원가입/로그인 기능을 구현해야 할 때가 있습니다. 인증 및 인가된 사용자에게 앱의 기능을 사용할 수 있게 하기 위해서는, 로그인이라는 Authentication(인증) 기능을 거치게 됩니다.

 

클라이언트 단에서 다음과 같이 서버로 로그인 정보를 POST로 보냅니다(로그인 시에는 보통 POST로 보냅니다).

{
    "username": "nx006",
    "password": "q1w2e3r4!"
}

그렇다면 서버에서는 이 정보가 맞는지를 검사하고, 유저 정보가 올바르고 권한이 있다면 인가(Permission)를 내줍니다.

 

이때 매번 우리가 서버로 요청을 보낼 때마다 아이디와 비밀번호를 입력하진 않습니다.

 

이때 앱을 실행할 때마다 사용자가 로그인을 하게끔 앱을 설계하는 것은 매우 비효율적입니다. 모든 요청을 보낼 때마다 사용자에게 아이디와 비밀번호를 다시 치게끔 하는 것은 UX를 극단적으로 떨어뜨리는 일입니다. 인증되지 않은 사용자에게 앱의 기능을 허락하지 않으면서 동시에, 사용자는 한 번의 로그인만으로 한동안 다시 로그인하는 일 없이 쾌적하게 앱을 사용할 수 있도록 Authentication 기능을 구성해야 합니다.

 

그러나 http 통신은 Stateless(무상태) 방식으로 연결되기에, 한 번 요청과 응답이 오간 후 연결이 끊어진 순간 서버는 클라이언트가 이전에 로그인하였는지 정보를 기억할 수 없습니다. 따라서 클라이언트는 자신이 조금 전 로그인을 하였다는 사실을 증명할 증서를 같이 보내야 합니다.

 

이때 이 증서를 무엇으로 사용하느냐에 따라 크게 두 가지로 나뉘는데, Session Based Authentication과 Token Based Authentication입니다.

 

Session 방식은, Session ID라는 것을 쿠키 속에 담아서 보냅니다. Token 방식은, JWT 등의 토큰 정보를 http header에 담아서 보내는 방식입니다.

 

최신의 앱들, 그리고 웹 어플리케이션들은 대부분 JWT(JSON Web Token) 기반의 Token 방식을 사용합니다. 그러나 과거의 어플리케이션들은 대부분 Session 방식을 사용하고 있기 때문에, 둘 다 아는 것이 중요합니다.

Session Based Authentication

세션 방식은 클라이언트가 Session ID를 쿠키 속에 담아서 전송하는 방식입니다.

 

세션 방식은 로그인(인증) 과정을 생략하기 위한 세션 ID를 발급받습니다. 서버는 이 세션 ID에 대응하는 세션 정보(유저 정보, 세션의 만료 시간 등)를 들고 있고(데이터베이스에 저장하고 있고), 클라이언트는 인증 과정에서 이 세션 ID를 서버로 보내서 로그인 과정을 생략합니다.

Session의 생성과 검증

Session 생성의 개요도. 각 프레임워크나 DBMS는 이해를 돕기 위한 자주 쓰이는 예시 제품입니다.
Session 생성의 개요도. 각 프레임워크나 DBMS는 이해를 돕기 위한 자주 쓰이는 예시 제품입니다.

  1. 클라이언트에서 API 서버로 ID와 패스워드를 전송합니다.
  2. API 서버에서 데이터베이스에 저장된 유저 정보와 비밀번호 정보를 대조해서 검증을 합니다.
  3. 검증이 완료되었고 올바른 유저라면, 세션 전용 데이터베이스에 세션을 생성해서 저장합니다.
    1. 이때 세션 정보에는 Session ID, User ID, time out, Authorization(권한) 등의 정보가 저장됩니다.
    2. Authorization을 저장하는 이유는, 유저가 인증되었다 하더라도 실제 요청을 보낼 수 있는 권한이 없을 수도 있기 때문입니다. 유저가 인증되고, 권한까지 유효해야 접근에 인가를 내줄 수 있습니다.
  4. 그리고 Session ID를 담은 쿠키를 클라이언트로 전송합니다.

그러면 클라이언트에서는 다음부터는 Session ID만을 가지고 인가를 받을 수 있습니다.

Session 검증의 개요도, 편의상 데이터베이스는 하나로 통합
Session 검증의 개요도, 편의상 데이터베이스는 하나로 통합

  1. 클라이언트에서 요청을 보낼 때, 쿠키에 저장된 세션 정보(세션 ID)를 같이 보냅니다.
  2. 서버는 세션 정보가 유효한 지 검증합니다.
  3. 세션 정보가 유효하다면, 데이터베이스에서 해당 세션을 들고 있는 유저가 누구인지 검색합니다.
  4. 데이터베이스는 해당 세션 ID에 해당하는 유저 정보를 반환합니다.
  5. 서버는 다시, 데이터베이스가 돌려준 유저 정보를 바탕으로 데이터를 질의합니다.
  6. 데이터베이스는 유저 정보를 바탕으로 데이터를 찾아 돌려줍니다.
  7. 이 데이터를 서버에서 잘 가공해서 클라이언트로 반환합니다.

Session Authentication의 장점

보안적인 신뢰성과 안전성

Session 방식의 최대 장점은, 바로 클라이언트 단에서 보안적인 이슈가 발생할 가능성이 매우 적습니다. 클라이언트는 Session ID 정보 하나만을 갖고 있습니다. 그러나 이 Session ID는 서버에서 랜덤하게 발급한 일련 번호일 뿐, 이 정보에는 유저의 어떠한 정보도 담겨 있지 않습니다. 그렇기 때문에 이 Session ID는 외부로 유출이 되어도 문제가 없으며, 그렇기 때문에 클라이언트 단에서는 보안적인 이슈가 생길 위험성이 매우 낮습니다.

Session Authentication의 단점

높은 I/O 연산 작업

여기서 세션 방식의 첫 번째 문제점을 확인할 수 있는데요, 세션 정보를 확인하는 과정에서 무조건 데이터베이스를 한 번 거친다는 점에서 상당히 높은 I/O 작업을 요구하게 됩니다.

 

그래서 I/O 연산 부하를 줄이기 위해, 세션 정보를 물리적으로 같은 서버의 메모리 공간에 DB 형태로 저장하기도 합니다. 그러나 이렇게 서버와 세션 데이터베이스를 물리적으로 같은 컴퓨팅 환경에 배치하면서 생기는 추가적인 문제점들이 있습니다. 아래에서 확인해보겠습니다.

낮은 확장성

단순히 높은 I/O 연산 작업만이 Session 방식의 단점은 아닙니다. Session 방식의 보다 근본적인 문제점은 확장성(Scalability)에 있습니다.

Session 방식 사용 시 문제점 - 서버의 수평적 확장의 어려움
Session 방식 사용 시 문제점 - 서버의 수평적 확장의 어려움

접속자 수가 증가하면서, 대량의 클라이언트 요청을 감당하기 위해서 서버는 확장성을 갖추어야 합니다.

 

이러한 확장성에는 수직적 확장(Scale Up)과 수평적 확장(Scale Out)의 방식이 있습니다. 수직적 확장의 경우 서버 자체의 메모리를 늘리거나 CPU 성능 등을 올리는 방식을 말합니다. 이 경우에는 세션 방식을 사용한다고 해도 별로 문제가 안 되는데, 문제는 수평적 확장 시 발생합니다.

 

수평적 확장은 서비스의 가용량을 늘리기 위해 서버를 여러 대로 분산하는 것을 의미합니다. 수직적 확장에 비해서 효과가 훨씬 더 좋기에 많이 사용되는 확장 기법입니다. 이때 여러 대의 서버를 뒤에 두고, 앞에 로드 밸런서(Load Balancer)를 두어서 트래픽을 분산합니다.

 

문제는 서버가 각각 세션 데이터베이스를 따로 두고 있을 경우 한 서버에 저장된 세션 정보를 다른 서버가 알지 못해서, 한 클라이언트(예를 들어 같은 IP의 클라이언트)에 대해서는 무조건 같은 서버로만 트래픽을 분산해야 합니다.

 

이런 로드밸런싱 방식을 Source IP Hash 방식이라고 합니다. 로드밸런싱 방식에는 Round Robin, Least Connection 등의 트래픽 분산에 특화된 여러 알고리즘이 있는데 이들을 강제로 포기해야 한다는 단점이 존재합니다.

MSA 방식의 아키텍처에서의 문제

수평적 확장의 어려움에서 비롯된 추가적인 문제가 있습니다. 바로 모놀리스(Monolith) 방식의 서버가 아닌 MSA 방식으로 서버 아키텍처를 구성할 때 생기는 문제가 있습니다.

 

MSA(Micro Service Architecture)란, 복합적인 역할을 하는 단일 서버를 두는 게 아니라, 하나의 서비스 안에서 개별적인 역할과 용도에 특화된 여러 서버를 복합적으로 운용하는 방식을 의미합니다.

Session 방식 사용 시 문제점 - MSA의 어려움
Session 방식 사용 시 문제점 - MSA의 어려움

만약에 blog에 관한 API 서버가 따로 있고, Video에 관한 서버가 또 따로 있다고 가정해봅시다. 이때 blog 서버의 경우는 세션 정보를 들고 있지만, 비디오 서버의 경우 세션 정보를 들고 있지 않습니다.

 

이 경우에 blog 관련 서버를 이용하는데 문제는 없지만, Video 서버를 이용하는데 다시 로그인을 해야 하는 문제가 생깁니다.

Session MSA 문제의 해법: 세션 전용 데이터베이스 분리
Session MSA 문제의 해법: 세션 전용 데이터베이스 분리

결국에 이 문제를 해결하기 위해서는, 세션 전용 데이터베이스를 따로 개설해야 합니다.

 

처음에 유저가 로그인에 성공하면 개별 서버에 있는 데이터베이스가 아니라, 공유된 세션 전용 데이터베이스에 세션 정보가 생깁니다. 그 이후부터는 어떠한 서버로 접근하든, 세션 정보를 공유할 수 있습니다.

세션 데이터베이스로의 트래픽 집중

그러나 위 경우에도, 문제가 또 있습니다.

 

애써 트래픽을 분산시키려고 서버를 나눠놨는데, 세션 정보를 조회하기 위해서는 다시 또 모든 트래픽이 하나의 세션 데이터베이스로 몰리는 문제가 발생합니다.

 

그래서 세션 데이터베이스를 적절한 방식으로 분배해야 합니다.

트래픽 문제의 해법: 샤딩
트래픽 문제의 해법: 샤딩

예를 들어서 범위 기반 샤딩(Shading)의 방식으로, 세션 ID 1번부터 1000번까지는 이쪽 데이터베이스로 가고, 세션 ID 1001번부터 2000번까지는 저쪽 데이터베이스로 간다… 이런 식으로 점점 복잡해지는 시스템을 구성해야 합니다.

 

이렇듯 세션 방식은 전통적이고 신뢰성 있는 방식이지만, 근본적으로 그 한계와 문제점들을 갖고 있었습니다. 이러한 문제점을 해결하기 위해서, 토큰 방식의 Authentication이 소개되었습니다.

Token Based Authentication

Token 방식은 클라이언트에서 http 헤더에 유저의 정보가 암호화되어 담긴 토큰을 보내면, 서버에서 데이터베이스를 거치지 않고 토큰이 유효한 지 검증하는 기술입니다.

 

데이터베이스를 거치지 않는다는 것이 중요합니다. 데이터베이스를 거치지 않고 서버에서 바로 처리가 가능하다는 장점 하나만으로 토큰 방식이 가져오는 여러 장점들이 생겨납니다.

Token의 생성과 검증

Token의 생성 개요도
Token의 생성 개요도

토큰 생성 방식은 매우 세션 방식에 비해서 매우 간단합니다.

  1. 클라이언트에서 서버로 ID와 패스워드를 전송합니다.
  2. 서버는 이 ID와 패스워드가 유효한 지 데이터베이스를 조회하여 검증합니다.
  3. ID와 패스워드가 유효하다면, Token을 클라이언트로 전송합니다.

그다음부터는 클라이언트는 토큰만 가지고 서버로부터 검증을 받을 수 있습니다.

Token 검증의 개요도
Token 검증의 개요도

  1. 클라이언트가 서버로 토큰을 전송합니다.
  2. 서버는 토큰을 검증합니다. 이때, 데이터베이스를 조회할 필요 없이 토큰만 갖고서 검증이 가능합니다.
  3. 검증이 완료되었다면, 필요한 데이터를 데이터베이스로 질의하고 응답받습니다.
  4. 결과를 전송합니다.

세션 방식에 비해서 훨씬 간결하고, 별도의 세션 정보 확인을 위한 데이터베이스를 거치지 않습니다. 토큰이 유효한 지를 서버에서 바로 처리합니다.

 

어떤 기술이 숨어있길래 토큰의 검증이 DB를 거치지 않고 가능할까요? 비밀은 JWT에 숨어 있습니다.

JWT (JSON Web Token)

RFC7519에서 JWT는 JSON 웹 토큰(JWT)은 두 당사자 간에 전송할 클레임(Claim)을 표현하는 데 있어서 컴팩트하고 URL-safe한 방법이라고 설명됩니다. JSON 객체를 JWE(JSON Web Encryption) 기술 등을 이용해 암호화한 토큰을 의미합니다. 암호화 시에는 RSA, ECDSA 등의 공개키 알고리즘으로 암호화됩니다.

 

여기서 Claim이란, key/value pair로 이루어진 정보의 집합을 의미합니다. key는 claim name, value는 claim value라고 표현합니다.

 

여기서의 관심사는 아니지만, JWT는 Authorization에만 사용되는 것은 아닙니다. 민감한 정보의 교환이 이루어지는 모든 곳에서 사용될 수 있습니다.

 

현대에서 토큰이라 함은 거의 대부분 JWT를 의미합니다.

 

JWT는 다음 특징을 갖고 있습니다.

  • Header, Payload, Signature로 이루어져 있다
  • Base64로 인코딩 되어있다
  • Header는 토큰의 종류와 암호화 알고리즘 등 토큰에 대한 정보가 들어있다
  • Payload는 발행일, 만료일, 사용자 ID 등 사용자 검증에 필요한 정보가 들어있다
  • Signature는 Base64로 인코딩된 Header와 Payload를 알고리즘으로 싸인한 값이 들어있다. 이 값을 기반으로 토큰이 발급된 뒤로 조작되었는지 확인할 수 있다

JWT는 Header, Payload, Signature로 구분되어 있다고 했는데, 이들은 점(.)으로 구분합니다. 그래서 모든 JWT는 xxxxx.yyyyy.zzzzz 형식으로 표현됩니다(x 부분이 header, y 부분이 payload, z 부분이 signature).

 

각 부분에 어떤 정보가 담기는지 살펴봅시다.

Header

헤더 부분에는 토큰의 타입(여기서는 “JWT”)과 어떤 알고리즘이 서명(Signing)에 사용되었는지를 표현합니다. 서명에 사용되는 알고리즘은 HMAC SHA256 혹은 RSA가 있습니다.

{"typ":"JWT",
 "alg":"HS256"}

그리고 이 헤더는 Base64Encoder로 인코딩됩니다. 예를 들어서 위 헤더 정보를 Base64로 인코딩하면 eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9 입니다. 이때 줄바꿈과 공백은 가독성을 위해 추가한 것일 뿐, 실제 인코딩 시에는 없애야 합니다.

Payload

Payload에는 Claim이 담깁니다. 선술했듯이 Claim은 key 역할을 하는 Claim name과 value 역할을 하는 Claim value으로 구성되어 있습니다. 엔티티(보통은 유저 정보)와 추가적인 데이터를 담고 있습니다.

{"iss":"joe",
 "exp":1300819380,
 "admin":true}

예를 들어 위와 같이 정보가 담길 수 있습니다. Payload는 또다시 Base64로 인코딩됩니다. 예를 들어 위 Payload를 인코딩하면 eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImFkbWluIjp0cnVlfQ 입니다.

 

좀 더 깊게 Payload 속 claim을 설명하면, “iss”와 “exp”는 미리 약속된 registered claims, “admin”은 직접 커스텀한 private claims라고 합니다.

  • Registered claims: 미리 정의된 claim들입니다. 필수는 아니나 권장되는, 유용한 claim들을 모아놓은 집합입니다.
    • iss(issuer): 토큰 발급자의 식별자
    • exp(expiration time): 토큰의 만료일
    • sub(subject): 토큰의 주체나 대상, 즉 토큰이 누구에 대한 정보를 담고 있는지를 표현합니다. 사용자 인증의 경우 sub claim은 사용자의 ID 등 식별자 명이 될 수 있습니다.
    • aud(audience): 토큰을 받는 주체, 의도된 수신 대상
    • 그 외에도 nbf(not before), iat(issued at), jti(JWT ID) 등의 약속된 claim들이 있습니다.
  • Public claims: JWT를 사용하는 사람들에 의해서 자유롭게 정의되는 claim들입니다. JWT는 원하는 대로 claim의 이름을 정의할 수 있습니다. 그러나 충돌을 방지하기 위해서 크게 두 가지 규칙이 존재합니다:
    • 해당 유니크한 이름을 공식적으로 IANA JSON Web Token Registry 에 등록해야 합니다.
    • 혹은 URI에 네임스페이스로 정의되어야 합니다.
    • RFC 5226에서 Public claim을 새롭게 등록하기 위한 가이드라인을 확인할 수 있습니다.
  • Private claims: JWT를 사용하는 모든 사람들에게 공유되는 claim 이름이 아니라, JWT를 사용하는 개개인이 직접 정의한 claim입니다. 충돌 가능성이 있습니다.

Registered claim에서 sub와 aud가 헷갈릴 수 있는데, sub는 토큰이 어떤 객체나 정보를 담고 있는지를 나타내며 유저의 정보가 이에 포함됩니다. aud는 토큰이 전송되어야 할 대상(서버나 서비스 등)을 나타냅니다. 만약에 토큰을 처리하려는 대상이 토큰을 받았는데 자신의 식별자를 aud claim에서 찾지 못한다면, 토큰이 유효하지 않다고 판단하고 거절할 수 있습니다.

 

JWT는 컴팩트한 토큰을 지향하고 있기 때문에, 모든 Registered claim name들은 3글자로만 이루어져 있습니다. 매 요청 시마다 네트워크로 토큰이 전송되니깐, Payload에는 최대한 적은 정보만 담는 게 좋습니다.

 

그리고 Private claims는 말 그대로 직접 서버와 클라이언트가 서로 약속해서 커스텀해서 claim을 집어넣을 수 있습니다. 유저의 권한 정보 등을 추가할 수 있습니다.

{"iss":"joe",
 "exp":1300819380,
 "admin":true}

참고로 위의 예제에서는 private claim으로 “admin” claim을 주었는데, 보다 충돌을 회피하는 안전한 방법은 uri로 namespace를 지정하는 것입니다.

{"iss":"joe",
 "exp":1300819380,
 "http://example.com/is_root":true}

이렇게 하는 게 RFC7519에서 더 권장하는 방법입니다. 물론, 이 경우에 Payload의 길이가 좀 더 길어진다는 단점은 있습니다.

 

그리고 주의해야 할 것이 있는데, Payload와 Header는 Base64로 인코딩되는데, 이 Base64는 해시 함수가 아닙니다! 디코딩이 가능한 포맷이며, 암호화가 되지 않습니다. 그래서 Payload에 Credit card number 같은 중요한 정보를 넣으면 안 됩니다.

Signature

JWT의 핵심은 바로 이 서명(Signature) 부분입니다. Signature는 header와 payload의 base64 인코딩 결과에, secret 키를 추가해서 이 전체를 SHA256 등의 해시로 암호화한 것입니다.

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret
)

header나 payload에서 단 한 글자라도 바뀌면, 그에 따라 base64 인코딩도 바뀌게 되고, 해시 함수의 결과 역시 완전히 달라지게 됩니다.

 

그래서 만약에 Payload나 Header에 변화가 생겼을 때, 이로 인한 해시의 결과가 Signature의 값과 완전히 달라지게 되므로 서버에서 위 변조 여부를 검증할 수 있습니다.

JWT의 예시
JWT의 예시, 사이트: https://jwt.io/

예를 들어서 위와 같은 JWT 토큰이 있다고 가정합니다. 실제 서버로 전송되는 정보는 왼쪽의 정보입니다.

 

여기서 Payload는 암호화가 되지 않는다고 했습니다. 그래서 공격자가 가운데 Payload에서, “name”이라는 정보를 바꾸었다고 가정해봅시다.

{"sub": "1234567890","name": "John Doe","iat": 1516239022}
// ->
{"sub": "1234567890","name": "nx006","iat": 1516239022}

name을 “nx006”로 바꿀 경우 Payload의 Base64 인코딩 결과는 eyJzdWIiOiAiMTIzNDU2Nzg5MCIsIm5hbWUiOiAibngwMDYiLCJpYXQiOiAxNTE2MjM5MDIyfQ 입니다.

Invalid Signature
Invalid Signature

그러나 Payload가 바뀔 경우, SHA256(base64.base64.secret) 이 부분의 결과도 달라지게 됩니다. 서버는 전송받은 Signature와, 실제 서버에서 Encryption 한 결과를 비교해서 토큰이 유효한 지 무효한 지 검증합니다. 두 결과가 다르다면 Invalid Signature가 되는 원리입니다.

JWT 사용 시 조심해야 할 점

Payload, Header는 암호화되지 않는다

Payload, Header는 암호화되지 않습니다. Base64는 인코딩과 디코딩 양방향이 가능하도록 설계되었습니다. 그래서 Payload에 신용카드 정보나 비밀번호 등의 민감한 정보를 담고 있을 경우 해커가 이를 그대로 살펴볼 수 있습니다.

Secret Key를 복잡하게 설정하자

비밀번호를 복잡하게 설정하기
비밀번호를 복잡하게 설정하기, Of course I still love you와 A short fall of gravitas는 SpaceX의 로켓 회수선의 이름입니다.

Secret Key는 길고 복잡하게 설정할수록 좋습니다. 한 가지 Tip은 “OfCourseIStillLoveYou,AShortfallOfGravitas”와 같이 긴 문장의 형태로 구사하는 것도 좋은 시크릿 키를 설정하는 방법이라고 합니다.

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  "OfCourseIStillLoveYou,AShortfallOfGravitas"
)

이 Secret Key를 쉽고 간단하거나, 혹은 대중적으로 많이 쓰이는 것으로 설정했다간, 무차별 대입 공격(Bruteforce attack, 브루트포스 공격)에 뚫릴 가능성도 있습니다.

 

Secret Key를 생성용, 검증용으로 구분해서 사용하는 방법도 있다고 합니다.

Unsecured JWT

{"alg":"none","typ":"JWT"}

간혹 위와 같이 header를 설정하는 경우도 있습니다. 예를 들어 위 헤더의 Base64 인코딩 결과는 eyJhbGciOiJub25lIiwidHlwIjoiSldUIn 입니다. 그래서 해커가 eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0 와 같은 헤더를 감지한다면, 바로 해당 토큰이 암호화되지 않았다고 알아차릴 수 있습니다. 혹은 위와 같은 헤더로 악성 유저가 서버로 요청을 보냈는데, 서버가 이를 허용해서 뚫리는 경우도 있다고 합니다.

 

이렇게 암호화 알고리즘을 설정하지 않아서, Signature가 생성되지 않는 JWT를 Unsecured JWT라 합니다. Unsecured JWT의 한 예시입니다.

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Im54MDA2IiwiaWF0IjoxNTE2MjM5MDIyfQ.

뒤에 signature에 아무것도 생성이 되지 않습니다.

JWT가 탈취당한다면?

만약에 JWT가 탈취당한다면 어떨까요?

 

세션 ID 그 자체로는 아무 의미 없는 세션 방식과 다르게, JWT는 내부적으로 유저의 정보를 포함하고 있습니다. 그래서 해커가 JWT를 클라이언트에서 가로챈다면 그 안에 담긴 정보를 볼 수 있습니다.

 

또한 탈취당한 JWT를 해커가 로그인하는 데 사용할 수 있으므로, 클라이언트에서 해당 토큰이 탈취당했다고 알려올 시 이를 정지시키는 기능을 만드는 것도 좋습니다.

 

또한 JWT의 유효 기간을 매우 짧게 설정하는 것이 좋습니다.

 

하지만 JWT의 유효 기간이 짧다면, 그만큼 새롭게 로그인을 해야 하는 횟수가 늘어날 수 있습니다. 이를 해결하기 위해 새로운 JWT 발급을 위한 Refresh Token을 따로 두어야 합니다.

Refresh Token과 Access Token

Refresh Token과 Access Token 모두 JWT입니다. Access Token은 실제 검증에 사용되고, Refresh Token은 이 Access Token을 재발급받기 위해서 사용됩니다.

 

때문에 Access Token은 유효 기간을 짧게, Refresh Token은 유효 기간을 길게 설정하는 것이 일반적입니다.

 

Access Token은 인증이 필요한 API 요청을 보낼 때 헤더에 항상 넣어서 보내기에, 자주 노출되고, 그렇기에 탈취 가능성이 높습니다. 그래서 유효 기간을 짧게 하는 게 유리합니다. 반면에 Refresh Token은 오직 Access Token을 재발급받는 데에만 사용되기에 탈취 가능성이 비교적 적은 편입니다.

 

그렇다면 만약에 Refresh Token이 만료된다면 어떨까요? 구현에 따라서 달라지겠지만, 자주 사용되는 방법으로는 Refresh Token이 만료된다면 그냥 로그아웃을 시킬 수 있습니다. 보통 서비스를 이용할 때 한 번 로그인을 해놓고서, 일정 기간이 지난 후에 다시 로그인을 하라고 하는 경우가 많이 있었을 것입니다. 이때 해당 앱 혹은 사이트 등 서비스가 Token 방식을 사용한다면, Refresh Token의 만료 기간이 로그인이 유지되는 기간과 같습니다.

Token 방식의 자세한 과정

Token 발급 과정

토큰 발급 과정
토큰 발급 과정, 출처: https://www.inflearn.com/course/%ED%94%8C%EB%9F%AC%ED%84%B0-%EC%8B%A4%EC%A0%84/dashboard

클라이언트에서 서버로 ID(username)와 패스워드를 전송할 때는, 다음 표준 사항을 거쳐야 합니다.

  1. “username:password” 값을 base64로 인코딩합니다.
    예를 들어서 username이 “nx006”이고, password가 “q1w2e3r4!”이라고 해보겠습니다. 이럴 경우 nx006:q1w2e3r4! 를 base64로 인코딩한 결과는 bngwMDY6cTF3MmUzcjQh 입니다.
  2. authorization 헤더에 “Basic $token” 형태로 전송합니다. 예를 들어서 위의 형태에서는 Basic bngwMDY6cTF3MmUzcjQh 의 형태가 됩니다.
  3. 서버에서는 JWT 토큰을 디코딩하여, 검증합니다.
  4. 검증이 완료되면, 서버는 클라이언트에게 Access Token과 함께 Refresh Token도 같이 전송합니다.

여기서 알 수 있는 주의해야 할 것은, 절대로 ID와 비밀번호에는 콜론(:)이 들어가면 안 됩니다.

Refresh Token 사용 과정

리프레시 토큰 사용 과정
리프레시 토큰 사용 과정, 사진 출처: 위와 동일

 

예를 들어서 refreshToken이 xxxxx.yyyyy.zzzzz 라고 해보겠습니다. 그렇다면 Access Token을 새로 발급받기 위해서 Authorization 헤더에다가 다음과 같이 넣어서 보내야 합니다.

authorization: "Bearer xxxxx.yyyyy.zzzzz"

서버에서 Refresh 검증이 완료되면, Access Token을 재발급합니다. 그리고 이를 재전송합니다.

Access Token 사용 과정

엑세스 토큰 사용 과정
엑세스 토큰 사용 과정, 사진 출처: 위와 동일

Access Token의 사용 과정을 단순화시키면 refreshToken과 거의 유사합니다. 단지 Bearer $refreshToken 대신 Bearer $accessToken 이 들어갈 뿐입니다.

토큰 인증 과정의 일반화

하지만 Access Token이 위의 차트에서는 간단하게 표현되었지만, 실제로는 이보다는 다소 복잡합니다.

 

클라이언트에서는 언제 Access Token이 언제 만료가 될지 알 수 없습니다. 물론 토큰 Payload에 만료 기한이 쓰여있긴 한데, 어차피 네트워크 전송 시간, 검증 시간에 따라서 언제 토큰이 서버에 도착하고 검증이 완료될지를 알 수 없기 때문에, 클라이언트에서는 Access Token이 만료되었다고 서버에서 에러를 던질 경우를 대비해서 로직을 작성해야 합니다.

토큰 인증 과정의 일반화
토큰 인증의 일반적인 과정, 사진 출처: 위와 동일

Access Token이 만료되었을 때의 로직이 위의 차트에 담겨 있습니다.

  1. 클라이언트는 Access Token을 헤더에 담아서 API 요청을 서버로 보냅니다.
  2. 서버에서는 Access Token을 검증합니다.
  3. 서버에서 토큰이 만료되었다는 것을 확인하면, 401(Unauthorized) 에러를 던집니다.
  4. 클라이언트는 401 에러를 확인하고, 새로운 Access Token을 발급받기 위해 Refresh Token을 헤더에 담아 재발급 URL(예를 들어 https://example.com/refresh)로 요청을 보냅니다.
  5. 서버에서 Refresh Token을 검증합니다.
  6. 유효한 토큰이라면, Access Token을 새롭게 발급하여 응답합니다.
    1. 이때 만약에 Refresh Token마저 만료가 돼서, 여기서도 401 에러가 난다면, 이때는 클라이언트에서 로그오프를 시킵니다. 이 시점부터는 다시 로그인을 하기 전까진 어떠한 토큰도 신뢰할 수 없습니다.
  7. 클라이언트는 이 새로운 Access Token으로 원래 보내던 요청을 다시 보냅니다.
  8. 서버에서는 Access Token을 검증하고(방금 새롭게 발급받았으니 통과될 것입니다), 적절하게 처리합니다.

클라이언트는 서버에서 보내주는 에러에 따라서 적절하게 새로운 요청을 보내거나, 혹은 로그오프를 시킬 수 있어야 하며, 서버에서는 클라이언트에서 요청과 함께 도착한 Authorization 헤더를 검증하고 적확한 에러 코드를 리턴할 줄 알아야 합니다.

Token Based Authentication의 장점

위에서 소개한 보안상의 위협들이 JWT의 단점으로 작용하기는 하지만, 이를 감수하고 쓸 만큼 JWT는 확실한 장점을 갖고 있습니다.

데이터베이스를 조회하는 횟수의 감소

세션 방식은 세션 ID를 조회하기 위해서 반드시 데이터베이스를 한 번 더 거쳐야 했습니다. 그러나 JWT는 데이터베이스를 거치지 않고도, 토큰 하나만 갖고서도 유효성을 검증할 수 있기 때문에, 높은 I/O 성능을 기대할 수 있습니다.

 

심지어 유저 정보를 조회할 때에도, 만약에 이미 토큰 안에 들어가 있는 정보만 필요할 경우, 이 단계에서도 데이터베이스를 조회하는 과정을 생략할 수 있습니다.

쉬운 수평 확장성

데이터베이스와 분리되었다는 장점 덕분에, 토큰 방식은 강력한 수평 확장성을 갖습니다. 서버에서 데이터베이스를 거치지 않고도 직접 토큰의 검증이 가능하기 때문에, 세션 데이터베이스처럼 검증용 데이터베이스를 따로 둘 필요도, 그리고 이 검증용 DB에 트래픽이 몰릴 이유도 없습니다.

 

비슷한 이유로 MSA 아키텍처에서도 강력한 힘을 발휘합니다.

 

이러한 장점들로 인해서, 현대에는 거의 대부분 Session보단 Token 방식을 선호하고 있습니다.

 

물론 Access Token이 탈취되었을 때 토큰이 만료되기까지 짧은 시간 동안은 토큰을 차단할 방법이 애매하다는 점 등, JWT도 단점이 있어, 실제 서비스에 사용할 때에는 잘 고민하고 적용해야 합니다.

결론 및 요약

비교 요소 Session Token
유저의 정보를 어디에서 저장하고 있는가? 서버 클라이언트
클라이언트에서 서버로 보내는 정보는? 쿠키 토큰
유저 정보 조회 시 DB를 이용해야 하는가? 확인 필요 토큰의 Payload에 들어있는 정보만 필요할 경우 DB 조회 불필요
클라이언트에서 인증 정보를 읽을 수 있는가? 불가능 가능
Horizontal Scaling이 쉬운가? 어려움 쉬움

 

위 표는 Token과 Session 방식을 비교 요약한 표입니다.

 

현대에서는 거의 대부분 토큰 기반 인증 방식을 채택합니다. 수평 확장성, 가용성, I/O 측면에서 세션 인증 방식보다 더 장점이 있기 때문입니다. 토큰 인증 방식은 JWT(JSON Web Token) 기술을 이용합니다.

 

사용되는 토큰은 인증을 위한 액세스 토큰과, 액세스 토큰이 만료되었을 시 새로이 발급하기 위한 목적의 리프레쉬 토큰으로 나뉩니다. 액세스 토큰은 자주 노출되어 탈취당하기 쉬운 만큼 유효 기간이 짧으며, 리프레쉬 토큰은 유효 기간이 긴 편입니다.

 

반면에 JWT의 Payload와 Header 등은 암호화되지 않으므로, 내부에 민감한 정보(비밀번호 등)를 담고 있으면 안 됩니다.

 

지금껏 세션 방식과 토큰 방식을 알아보며, 세션 방식에서 어떠한 문제점이 있었고 토큰 방식에서는 이 문제점이 어떻게 해결되었는지를 알아봤습니다. 그리고 토큰 방식에서 보안상 생각해야 할 것들에 대해서도 알아보았습니다.

📚 Reference