JWT(JSON Web Token)는 현대 웹 애플리케이션과 API 환경에서 상태 비저장(Stateless) 인증 및 정보 교환을 위한 컴팩트하고 자가 포함적인(self-contained) 표준 방식(RFC 7519)으로 널리 사용됩니다. 하지만 JWT의 편리함 이면에는 반드시 고려해야 할 보안적 측면과 구현상의 복잡성이 존재합니다.
이 글에서는 JWT의 기본 구조와 작동 원리를 시작으로, 사용 이유와 장단점, 안전한 저장 및 관리 전략(특히 Refresh Token 포함), 그리고 쿠키의 보안 옵션(SameSite, HttpOnly, Secure)까지 심층적으로 다룹니다. 각 섹션의 끝에는 독자의 이해를 돕기 위한 질문과 전문가(교수)와 학습자(제자)의 대화를 포함하여 개념을 명확히 하고 실무적인 관점을 더합니다.
1. JWT란 무엇인가?
1.1 개념
JWT(JSON Web Token)는 두 개체(주로 클라이언트와 서버) 간에 정보를 JSON 객체 형태로 안전하게 전송하기 위한 개방형 표준(RFC 7519)입니다. 이 정보는 디지털 서명되어 있으므로 신뢰성(Authenticity)과 무결성(Integrity)을 검증할 수 있습니다. 즉, JWT는 "서버가 발급했고, 중간에 위변조되지 않았음"을 수신 측에서 확인할 수 있는 토큰입니다.
주요 용도는 다음과 같습니다.
- 인가(Authorization): 사용자가 로그인하면 서버는 후속 요청에서 해당 사용자가 접근 권한이 있는 리소스(라우트, 서비스, 파일 등)에 접근할 수 있도록 JWT를 발급합니다.
- 정보 교환(Information Exchange): 두 개체 간에 정보를 안전하게 전송하는 방법입니다. JWT의 서명(예: 공개키/비밀키 쌍 사용)을 통해 수신자는 발신자가 누구인지 확신할 수 있습니다.
JWT의 중요한 특징 중 하나는 서버가 클라이언트의 인증 상태를 세션 저장소에 유지할 필요 없이, 클라이언트가 제시하는 유효한 JWT만 검증하면 되므로 상태 비저장(Stateless) 아키텍처 구현에 용이하다는 점입니다.
1.2 구조: 헤더, 페이로드, 서명
JWT는 마침표(.)로 구분되는 세 부분으로 구성됩니다:
- 헤더 (Header): 토큰의 유형(일반적으로 "JWT")과 사용된 서명 알고리즘(예: HMAC SHA256 또는 RSA)을 지정하는 JSON 객체입니다.
-
{ "alg": "HS256", // 서명 알고리즘 (HMAC SHA256) "typ": "JWT" // 토큰 유형 (JSON Web Token) }
-
- 페이로드 (Payload): 토큰에 담겨 전달될 실제 정보 조각인 클레임(Claim)들을 포함하는 JSON 객체입니다. 클레임은 사용자의 신원 정보(예: ID, 이름)나 권한, 토큰 만료 시간 등 다양한 정보를 나타냅니다. 클레임에는 세 가지 유형이 있습니다 (RFC 7519, Section 4.1):
- 등록된 클레임 (Registered Claims): 표준으로 정의된 클레임으로, 필수는 아니지만 권장됩니다 (예: iss(발급자), exp(만료시간), sub(주제), aud(수신자)).
- 공개 클레임 (Public Claims): 충돌 방지를 위해 URI 형태로 정의되는 클레임입니다.
- 비공개 클레임 (Private Claims): 통신하는 당사자 간에 협의하여 사용하는 클레임입니다. 이름 충돌에 주의해야 합니다.
{ "sub": "user-12345", // 등록된 클레임: 토큰 주체(사용자 ID) "name": "Alice", // 비공개 클레임: 사용자 이름 "admin": true, // 비공개 클레임: 관리자 여부 "iat": 1516239022, // 등록된 클레임: 발급 시각 (Unix Timestamp) "exp": 1516242622 // 등록된 클레임: 만료 시각 (Unix Timestamp) }
- 서명 (Signature): 헤더와 페이로드가 위변조되지 않았음을 검증하고, 토큰 발급자의 신뢰성을 보장하는 부분입니다. 생성 방식은 다음과 같습니다:
- Base64Url 인코딩된 헤더와 페이로드를 점(.)으로 연결합니다 (encodedHeader + "." + encodedPayload).
- 이 문자열을 헤더의 alg 필드에 지정된 알고리즘(예: HMAC SHA256)과 서버만 아는 비밀 키(Secret) (또는 RSA/ECDSA의 경우 개인 키)를 사용하여 서명합니다.
- 결과로 생성된 서명 값을 Base64Url 인코딩합니다.
최종적으로 JWT는 Base64Url(Header).Base64Url(Payload).Base64Url(Signature) 형태의 문자열로 표현됩니다.
1.3 JWT는 암호화가 아닌 인코딩 (Base64)
중요: 헤더와 페이로드는 단순히 Base64Url 방식으로 인코딩된 것일 뿐, 암호화된 것이 아닙니다. 따라서 누구나 JWT 문자열을 받으면 헤더와 페이로드 부분을 디코딩하여 내용을 쉽게 확인할 수 있습니다.
절대로 비밀번호, 주민등록번호, 신용카드 번호와 같은 민감한 개인 정보를 페이로드에 포함해서는 안 됩니다. 만약 민감 정보를 전달해야 한다면, JWT 자체를 암호화하는 JWE(JSON Web Encryption, RFC 7516) 표준을 고려해야 합니다. 하지만 일반적으로 JWT는 서명(JWS, RFC 7515)을 통해 무결성과 발신자 인증에 초점을 맞춥니다.
[교수와 제자의 대화]
- 학생: 교수님, JWT 구조를 보니 헤더와 페이로드는 그냥 Base64 인코딩이라 누구나 볼 수 있군요. 그럼 서명(Signature) 부분이 가장 중요한 보안 장치겠네요?
- 교수: 정확하네. 서명은 두 가지 핵심 역할을 하지. 첫째, 헤더나 페이로드 내용이 조금이라도 변경되면 서명 검증에 실패하므로 무결성(Integrity)을 보장하네. 둘째, 서명 생성에 사용된 비밀 키(또는 개인 키)는 서버만 가지고 있으므로, 유효한 서명은 이 토큰이 신뢰할 수 있는 서버로부터 발급되었다는 증거(Authenticity)가 되는 걸세.
- 학생: 그럼 페이로드에 사용자 ID나 권한 같은 걸 넣어도 괜찮은 건가요? 누구나 볼 수 있다면서요.
- 교수: 좋은 질문이야. 사용자 ID나 역할(예: 'admin') 같은 정보는 그 자체로 비밀 정보는 아니지 않나? 중요한 것은 이 정보가 '위변조되지 않았고, 신뢰할 수 있는 서버가 부여한 정보'라는 점일세. 물론, 사용자 ID조차 노출시키고 싶지 않은 시나리오도 있을 수 있겠지만, 일반적인 인가 목적에서는 사용자 식별자와 권한 정보를 페이로드에 담는 것이 일반적이라네. 다만, 다시 강조하지만, 비밀번호 같은 민감 정보는 절대 금물일세!
📌 질문:
- "JWT에 담긴 Payload는 누구나 볼 수 있다. 그렇다면 비밀번호 같은 민감정보를 보관하면 어떤 문제가 발생할까요?"
- 답변 유도: 누구나 디코딩하여 비밀번호를 알아낼 수 있으므로 심각한 보안 사고로 이어집니다. JWT는 기밀성(Confidentiality)을 보장하지 않습니다.
2. JWT를 사용하는 이유: Stateless의 매력
JWT가 널리 채택된 주된 이유는 다음과 같습니다.
- 상태 비저장(Stateless) 및 확장성: JWT의 가장 큰 장점입니다. 서버는 클라이언트의 인증 상태를 세션 저장소(메모리, DB, Redis 등)에 유지할 필요가 없습니다. 클라이언트가 요청 시 제시하는 JWT의 서명과 유효기간만 검증하면 됩니다. 이는 서버 부하를 줄이고, 서버를 수평적으로 확장(Scale-out)하기 쉽게 만듭니다. 로드 밸런서 뒤에 여러 대의 서버 인스턴스가 있어도 세션 동기화 문제 없이 동일한 JWT를 모든 서버에서 검증할 수 있습니다. 마이크로서비스 아키텍처(MSA) 환경에서 서비스 간 인증/인가 전달에도 유용합니다.
- 자가 포함적(Self-contained): JWT 페이로드 안에 인증 및 인가에 필요한 정보(사용자 ID, 역할, 권한 등)를 담을 수 있습니다. 서버는 토큰 자체만으로 필요한 정보를 얻을 수 있어, 추가적인 데이터베이스 조회 없이도 사용자를 식별하고 권한을 확인할 수 있습니다 (물론, 최신 권한 정보 확인을 위해 DB 조회가 필요한 경우도 있습니다).
- 다양한 환경에서의 사용 용이성: 웹(브라우저), 모바일 앱, 서버 간 통신 등 다양한 플랫폼과 환경에서 쉽게 사용할 수 있습니다. HTTP 헤더(주로 Authorization: Bearer <token>)를 통해 전달하는 것이 일반적이며, 이는 RESTful API 설계와 잘 맞습니다.
- 표준 기반: RFC 7519라는 개방형 표준에 기반하므로, 다양한 언어와 프레임워크에서 라이브러리를 쉽게 찾을 수 있고 상호 운용성이 좋습니다.
[교수와 제자의 대화]
- 학생: 서버가 상태를 저장하지 않는다는 게 정말 매력적이네요! 서버 여러 대로 늘려도 세션 걱정 안 해도 되고요. 그럼 서버는 그냥 JWT만 받아서 확인하면 끝인가요?
- 교수: 기본적으로는 그렇다네. 서버가 매 요청마다 해야 할 일은 크게 세 가지지. 첫째, 토큰의 서명이 유효한지 (즉, 위변조되지 않았고 우리가 발급한 게 맞는지) 서버의 비밀 키로 검증하고, 둘째, 토큰의 유효 기간(exp 클레임)이 지나지 않았는지 확인하고, 셋째, 필요하다면 토큰 발급자(iss)나 수신자(aud) 같은 다른 클레임들도 정책에 맞는지 검증하는 걸세. 이 검증만 통과하면 페이로드에 담긴 정보를 믿고 사용할 수 있는 거지.
- 학생: DB 조회 없이도 사용자 정보를 알 수 있다는 것도 장점이겠네요.
- 교수: 그렇지. 토큰에 필요한 정보를 담아두면 DB 부하를 줄일 수 있지. 다만, 여기에도 트레이드오프가 있네. 토큰에 너무 많은 정보를 담으면 토큰 크기가 커져서 네트워크 오버헤드가 발생할 수 있고, 토큰 발급 이후 사용자 정보(예: 권한)가 변경되었을 때 토큰에는 예전 정보가 남아있을 수 있다는 점도 고려해야 하네.
📌 점검:
- "JWT의 가장 큰 장점은 서버가 인증 상태를 별도로 저장하지 않는다는 점입니다.
그렇다면 서버가 매 요청마다 해야 할 일은 무엇일까요?" - 답변 유도: 1. 서명 검증, 2. 만료 시간 검증, 3. 필요시 기타 클레임(iss, aud 등) 검증
3. JWT의 단점과 주의점: 만능은 아니다
JWT는 강력하지만, 다음과 같은 단점과 고려해야 할 사항들이 있습니다.
- 토큰 탈취 시 대처의 어려움: JWT는 한번 발급되면 유효 기간이 만료될 때까지 계속 유효합니다. 만약 공격자가 JWT(특히 Access Token)를 탈취한다면, 유효 기간 동안 해당 사용자로 위장하여 시스템에 접근할 수 있습니다. 서버 측에서 특정 토큰을 강제로 무효화하기 어렵기 때문입니다. (세션 방식은 서버에서 해당 세션 정보를 삭제하면 즉시 무효화 가능)
- 대응책:
- 짧은 만료 시간: Access Token의 유효 기간을 매우 짧게(예: 5분~15분) 설정하여 탈취 시 피해 범위를 줄입니다.
- Refresh Token 사용: Access Token 갱신 전용 토큰을 별도로 사용하여 사용자 경험을 유지합니다 (자세한 내용은 후술).
- 토큰 블랙리스트 관리: 무효화해야 하는 토큰 목록을 서버(DB, Redis 등)에 저장하고, 매 요청 시 블랙리스트에 있는지 확인합니다. 이는 JWT의 Stateless 장점을 일부 희생하는 방식입니다.
- 대응책:
- 페이로드 정보 노출: 앞서 강조했듯이, 페이로드는 Base64 인코딩일 뿐 암호화가 아닙니다. 누구나 디코딩하여 내용을 볼 수 있으므로, 민감 정보 포함은 절대 금물입니다.
- 토큰 길이: 페이로드에 담는 정보가 많아지거나, RSA/ECDSA 같이 긴 키를 사용하는 서명 알고리즘을 사용하면 JWT의 길이가 상당히 길어질 수 있습니다. 이는 매 요청마다 HTTP 헤더에 포함되어 전송되므로, 네트워크 대역폭 소비 증가 및 성능 저하로 이어질 수 있습니다.
- 구현 복잡성: 안전하게 JWT를 사용하려면 만료 시간 관리, Refresh Token 전략, 안전한 저장 방법, 서명 알고리즘 선택, 키 관리 등 고려해야 할 사항이 많아 구현 복잡도가 증가할 수 있습니다.
[교수와 제자의 대화]
- 학생: 토큰이 한번 털리면 만료될 때까지 막을 방법이 딱히 없다는 게 좀 무서운데요? 세션은 그냥 서버에서 지우면 되잖아요.
- 교수: 그게 바로 JWT의 가장 큰 약점이자 트레이드오프일세. Stateless의 편리함을 얻는 대신, 한번 발급한 토큰의 통제권을 어느 정도 잃게 되는 셈이지. 그래서 Access Token의 유효 기간을 극단적으로 짧게 가져가는 전략이 중요한 걸세. 15분짜리 토큰이 털려도 15분 뒤에는 쓸모없어지니까.
- 학생: 그럼 그 15분 동안은 어쩔 수 없는 건가요? 블랙리스트 방식은 Stateless 장점을 없앤다고 하셨고요.
- 교수: 블랙리스트가 완벽한 Stateless는 아니지만, 필요하다면 고려할 수 있는 현실적인 절충안일세. 혹은 아주 민감한 작업(예: 비밀번호 변경, 결제)을 수행할 때는 JWT 외에 추가적인 인증(예: 비밀번호 재확인)을 요구하는 방법도 있지. 즉, JWT만으로 모든 상황을 해결하려 하기보다는, 상황에 맞는 추가적인 보안 계층을 설계하는 것이 중요하네. Refresh Token 전략도 이런 맥락에서 나온 것이고.
📌 점검:
- "JWT를 실무에 적용했을 때, 토큰을 강제로 무효화해야 하는 상황이 생겼다면 어떤 해결책들이 있을까요?"
- 답변 유도:
- 1. 짧은 만료 기간 설정
- 2. 서버 측 토큰 블랙리스트 구현
- 3. Refresh Token 활용 및 관리
- 4. 민감 작업 시 추가 인증 요구
4. JWT vs. 전통적인 세션 방식 비교
항목 | JWT (JSON Web Token) | Session (서버 기반 세션) |
상태 저장 | Stateless (서버에 상태 저장 안 함) | Stateful (서버에 세션 정보 저장) |
저장 위치 | 클라이언트 측 (주로 Local Storage/Cookie) | 서버 측 (메모리, DB, Redis 등 세션 스토리지) |
서버 확장성 | 높음 (세션 동기화 불필요) | 낮음 (세션 동기화 또는 Sticky Session 필요) |
토큰/ID 검증 | 서명 검증, 만료 시간 검증 | 세션 저장소 조회 및 유효성 검증 |
정보 포함 | 토큰 자체에 정보 포함 가능 (Self-contained) | 세션 ID만 전달, 정보는 서버 세션 저장소에 |
무효화 | 구현 어려움 (만료 또는 블랙리스트 필요) | 비교적 쉬움 (서버에서 세션 삭제) |
보안 관리 | 토큰 탈취 시 만료 전까지 유효 | 서버에서 세션 강제 종료 가능 |
보안 고려사항 | 토큰 저장 위치 보안(XSS, CSRF) 중요 | 세션 저장소 보안, 세션 하이재킹 방어 중요 |
주요 사용처 | RESTful API, MSA, 모바일 앱 인증/인가 | 전통적인 웹 애플리케이션(서버 렌더링) |
[교수와 제자의 대화]
- 학생: 표를 보니 장단점이 명확하네요. 확장성이 중요하면 JWT, 서버에서 통제하는 게 더 중요하면 세션 방식이 좋아 보이는데요?
- 교수: 정확히 봤네. 일반적으로 마이크로서비스 환경처럼 서버가 분산되어 있고 수평 확장이 중요하다면 JWT가 매우 유리하지. 반면, 사용자의 활동을 서버에서 실시간으로 추적하거나 특정 사용자를 즉시 로그아웃시켜야 하는 등 강력한 서버 측 통제가 필요하다면 세션 방식이 더 적합할 수 있네.
- 학생: 그럼 보안성은 어디가 더 좋다고 말하기 어려운 건가요?
- 교수: 그렇지. '어떤 방식이 절대적으로 더 안전하다'라고 말하기는 어렵다네. JWT는 토큰 자체의 보안(탈취 방지, 안전한 저장)이 중요하고, 세션은 서버 측 세션 저장소의 보안과 세션 ID 탈취(세션 하이재킹) 방어가 중요하지. 결국 어떤 방식을 선택하든 그 방식의 취약점을 잘 이해하고 적절한 보안 대책을 구현하는 것이 핵심일세. 확장성과 보안 요구사항, 개발 복잡성 등을 종합적으로 고려해서 시스템에 맞는 방식을 선택해야 하네.
📌 점검:
- "JWT와 세션 중에 선택할 때, 확장성과 보안성을 모두 고려한다면 어떤 선택이 더 적합할까요? 이유도 간단히 설명해보세요!"
- 답변 유도: 정답은 없다. 확장성이 최우선이면 JWT, 서버 측 통제와 즉시 무효화가 중요하면 세션. 각각의 보안 취약점(JWT: 토큰 탈취/저장, 세션: 세션 하이재킹/저장소)을 이해하고 방어 전략을 세우는 것이 중요.
5. JWT 서명(Signature)의 핵심 역할
JWT의 보안은 상당 부분 서명에 의존합니다.
5.1 서명 생성 과정 (예: HS256)
가장 널리 쓰이는 HMAC SHA256 알고리즘의 경우, 서명은 다음과 같이 생성됩니다 (RFC 7515, JWS 기반).
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
your-256-bit-secret
)
- Base64Url 인코딩된 헤더와 페이로드를 점(.)으로 연결합니다.
- 이 결과 문자열과 서버만 알고 있는 비밀 키(Secret)를 HMAC SHA256 함수의 입력으로 사용합니다.
- 함수의 출력(해시 값)이 바로 서명이 됩니다. 이 값을 Base64Url 인코딩하여 최종 JWT의 세 번째 부분으로 사용합니다.
- RSA나 ECDSA 같은 비대칭키 알고리즘을 사용하는 경우, 서버는 개인 키(Private Key)로 서명하고, 검증 측(다른 서버 또는 클라이언트)은 서버의 공개 키(Public Key)로 서명을 검증합니다. 이는 발급 서버와 검증 서버가 다른 경우나, 클라이언트가 직접 검증해야 할 때 유용합니다.
5.2 서명의 중요성
- 무결성 보장 (Integrity): 만약 누군가 JWT의 헤더나 페이로드 내용을 단 한 비트라도 변경한다면, 동일한 비밀 키로 다시 계산한 서명 값은 원래의 서명 값과 달라집니다. 서버는 수신된 토큰의 서명을 직접 계산하여 포함된 서명과 비교함으로써 토큰이 전송 중에 변경되지 않았음을 확신할 수 있습니다. (클라이언트 => 서버)
- 발신자 인증 (Authenticity): HMAC 알고리즘의 경우, 서명 생성에 사용된 비밀 키는 서버만 알고 있습니다. 따라서 서명 검증에 성공했다는 것은 해당 토큰이 올바른 비밀 키를 가진 서버(즉, 신뢰할 수 있는 발급자)에 의해 발급되었음을 증명합니다. (비대칭키 방식의 경우, 해당 개인 키 소유자가 서명했음을 증명합니다.) (서버 => 클라이언트)
[교수와 제자의 대화]
- 학생: 서명 검증이 실패하는 경우는 어떤 때일까요? 단순히 토큰이 변조된 경우 말고도 다른 이유가 있을까요?
- 교수: 아주 좋은 질문일세. 서명 검증 실패의 가장 흔한 원인은 물론 토큰 위변조지. 하지만 그 외에도 여러 가능성이 있다네. 예를 들면:
- 잘못된 비밀 키(Secret) 사용: 검증하는 서버가 토큰 발급 시 사용된 비밀 키와 다른 키를 사용하면 당연히 검증에 실패하겠지. 서버가 여러 대일 경우 키 동기화가 중요하네.
- 알고리즘 불일치: 헤더의 alg 필드와 다른 알고리즘으로 검증을 시도하는 경우. (예: HS256으로 서명됐는데 RS256으로 검증하려 할 때)
- 토큰 손상: 토큰 문자열 자체가 전송 과정이나 저장 중에 손상되어 일부가 변경되거나 누락된 경우.
- (비대칭키 경우) 잘못된 공개키 사용: RSA/ECDSA 서명을 검증할 때, 발급자의 개인 키에 대응하는 올바른 공개키가 아닌 다른 공개키를 사용한 경우.
어떤 경우든 서명 검증 실패는 심각한 보안 문제의 신호일 수 있으므로, 서버는 즉시 해당 요청을 거부해야 하네.
📌 점검:
- "JWT 시그니처 검증이 실패한다면, 어떤 상황들이 원인일 수 있을까요? 2~3가지 이상 떠올려보세요."
- 답변 유도: 1. 토큰 위변조, 2. 잘못된 비밀 키/공개 키 사용, 3. 알고리즘 불일치, 4. 토큰 문자열 손상
6. JWT 저장소: 로컬 스토리지 vs. 쿠키 (보안 중심 비교)
발급된 JWT(Access Token 또는 Refresh Token)를 클라이언트 측 어디에 저장할지는 웹 보안의 핵심적인 결정 사항입니다. 주로 로컬 스토리지(Local Storage)와 쿠키(Cookie)가 사용되며, 각각 장단점과 보안적 고려사항이 뚜렷합니다.
6.1 로컬 스토리지 (Local Storage)
- 장점:
- JavaScript를 통해 쉽게 접근하고 제어할 수 있습니다. (토큰 저장, 읽기, 삭제 용이)
- 용량이 쿠키보다 큽니다 (보통 5-10MB).
- CSRF(Cross-Site Request Forgery) 공격에 상대적으로 덜 취약합니다. 토큰이 HTTP 요청에 자동으로 포함되어 전송되지 않으므로, JavaScript 코드로 직접 Authorization 헤더에 담아 보내야 합니다.
- 단점:
- XSS(Cross-Site Scripting) 공격에 매우 취약합니다. 만약 웹사이트에 XSS 취약점이 존재하여 악성 스크립트가 삽입되면, 해당 스크립트는 localStorage.getItem('token')과 같은 코드를 실행하여 저장된 토큰을 매우 쉽게 탈취할 수 있습니다.
- 일단 토큰이 탈취되면, 공격자는 유효 기간 동안 해당 사용자로 위장할 수 있으며, 서버 측에서 이를 막기 어렵습니다 (특히 Access Token).
6.2 쿠키 (Cookie)
- 장점:
- HttpOnly 속성을 사용하면 JavaScript에서의 쿠키 접근을 차단하여 XSS 공격으로 인한 토큰 탈취 위험을 크게 줄일 수 있습니다. 이것이 쿠키를 선호하는 가장 큰 이유 중 하나입니다.
- 서버에서 Set-Cookie 헤더를 통해 전달하면, 브라우저가 이후 동일 도메인 요청 시 자동으로 쿠키를 포함시켜 보내므로 클라이언트 측 코드가 간결해집니다 (단, 이는 CSRF의 원인이 되기도 합니다).
- 만료 시간(Max-Age 또는 Expires), 적용 경로(Path), 도메인(Domain) 등 세밀한 제어가 가능합니다.
- 단점:
- CSRF(Cross-Site Request Forgery) 공격에 취약해질 수 있습니다. 브라우저가 요청 시 자동으로 쿠키를 포함시키는 특성 때문에, 사용자가 악의적인 사이트에 접속했을 때 자신도 모르게 특정 요청(예: 비밀번호 변경)이 원래 사이트로 전송될 수 있습니다. (이를 막기 위해 SameSite 속성이 중요)
- 용량 제한이 작습니다 (약 4KB). 긴 JWT나 많은 쿠키는 문제가 될 수 있습니다.
- CORS(Cross-Origin Resource Sharing) 상황이나 서브도메인 간 쿠키 공유 시 설정이 복잡해질 수 있습니다.
6.2.1 쿠키의 핵심 보안 속성 (RFC 6265 & 6265bis 기반)
안전한 쿠키 사용을 위해 다음 속성들을 이해하고 설정하는 것이 필수적입니다.
- HttpOnly:
- 목적: XSS 공격 방어.
- 동작: 이 속성이 설정된 쿠키는 document.cookie와 같은 JavaScript API를 통해 접근할 수 없습니다. 오직 HTTP(S) 요청을 통해서만 서버로 전송됩니다.
- 설정 예시: Set-Cookie: token=...; HttpOnly
- Secure:
- 목적: 중간자 공격(MITM) 방지.
- 동작: 이 속성이 설정된 쿠키는 오직 HTTPS 프로토콜을 사용하는 암호화된 요청에만 포함되어 전송됩니다. HTTP 요청에는 전송되지 않습니다.
- 설정 예시: Set-Cookie: token=...; Secure (HTTPS 사이트에서만 의미 있음)
- SameSite:
- 목적: CSRF 공격 방어.
- 동작: 다른 출처(Cross-Site)에서 발생한 요청에 대해 쿠키 전송을 제어합니다. 주요 값은 다음과 같습니다.
- Strict: 가장 엄격. 다른 사이트에서 시작된 모든 종류의 요청(링크 클릭, 폼 제출 등)에 대해 쿠키를 전송하지 않습니다. CSRF 방어에는 가장 강력하지만, 외부 사이트에서 내 사이트로 링크를 통해 이동했을 때 로그인이 풀리는 등 사용자 경험(UX)을 해칠 수 있습니다.
- Lax: 기본값(대부분의 최신 브라우저). GET 요청 중 최상위 탐색(Top-level navigation, 예: 주소창 입력, 링크 클릭)과 같이 비교적 안전하다고 판단되는 경우에만 다른 사이트에서 시작된 요청이라도 쿠키를 전송합니다. POST 요청이나 <iframe>, <img> 로딩 등에는 쿠키를 보내지 않아 대부분의 CSRF를 방어하면서도 UX 저하를 최소화합니다.
- None: 모든 종류의 Cross-Site 요청에 대해 쿠키를 전송합니다. 이는 서드파티 컨텍스트(예: <iframe> 내에서의 인증)에서 필요할 수 있지만, CSRF에 취약해집니다. None을 사용하려면 반드시 Secure 속성을 함께 지정해야 합니다. (브라우저 정책)
- 설정 예시: Set-Cookie: token=...; SameSite=Lax (또는 Strict, None; Secure)
결론: 보안 관점에서 보면, 토큰(특히 Refresh Token)은 HttpOnly, Secure, SameSite=Lax (또는 Strict) 속성이 적용된 쿠키에 저장하는 것이 XSS와 CSRF 위험을 동시에 줄이는 가장 권장되는 방법입니다. 로컬 스토리지는 XSS 위험 때문에 민감한 토큰 저장에는 부적합하다는 의견이 지배적입니다.
[교수와 제자의 대화]
- 학생: 로컬 스토리지는 XSS에 너무 위험하군요. 그럼 토큰은 무조건 쿠키에 저장해야 하나요?
- 교수: '무조건'이라고 할 수는 없지만, 보안을 중시한다면 특히 Refresh Token 같은 민감한 토큰은 HttpOnly 쿠키가 현재로서는 가장 합리적인 선택지라네. Access Token의 경우, 만료 기간이 매우 짧다면 로컬 스토리지나 세션 스토리지(Session Storage)에 저장하고 JavaScript로 관리하는 방식도 고려될 수 있지만, 여전히 XSS 위험은 감수해야 하지.
- 학생: 쿠키를 쓰면 CSRF가 걱정된다고 하셨는데, SameSite=Lax만으로 충분한가요?
- 교수: SameSite=Lax가 대부분의 일반적인 CSRF 공격을 효과적으로 막아준다네. 하지만 모든 시나리오를 완벽하게 커버하는 것은 아니지. 예를 들어, GET 요청을 통한 상태 변경(원칙적으로는 지양해야 하지만)이나 특정 브라우저의 동작 방식 차이 등이 있을 수 있네. 그래서 더 강력한 CSRF 방어가 필요하다면, 전통적인 방식인 CSRF 토큰 (서버가 발급하여 폼이나 헤더에 포함시키는 비밀값)을 함께 사용하거나, Double Submit Cookie 패턴(서버가 생성한 CSRF 토큰 값을 쿠키와 요청 파라미터 양쪽에 넣어 비교하는 방식) 같은 추가적인 방어책을 구현하는 것이 좋네. 하지만 SameSite 속성의 도입으로 CSRF 방어가 이전보다 훨씬 수월해진 것은 사실일세.
📌 점검:
- "로컬스토리지에 토큰을 저장했을 때, 가장 큰 보안 위험은 무엇일까요?
그리고 쿠키에 저장한다면 어떤 속성들을 왜 설정해야 할까요?" - 답변 유도:
- 로컬스토리지 위험:
- XSS 공격으로 토큰 탈취 용이.
- 쿠키 속성: HttpOnly(XSS 방어), Secure(중간자 공격 방지), SameSite=Lax/Strict(CSRF 방어)
- 로컬스토리지 위험:
7. Access Token과 Refresh Token 전략: 보안과 편의성의 균형
JWT의 단점 중 하나인 '탈취 시 만료 전까지 유효' 문제를 완화하고 사용자 경험을 개선하기 위해, Access Token과 Refresh Token을 함께 사용하는 전략이 널리 쓰입니다.
7.1 왜 두 개의 토큰이 필요한가?
- Access Token:
- 역할: 실제 API 요청 시 사용자 인증 및 인가를 위해 사용됩니다.
- 특징: 만료 기간이 매우 짧습니다 (예: 5분, 15분, 1시간). 서버는 매 요청 시 이 토큰의 유효성(서명, 만료)만 검사하면 됩니다. 페이로드에 사용자 식별 정보, 권한 등을 포함할 수 있습니다.
- 보안: 탈취되더라도 짧은 시간 내에 만료되므로 피해 범위가 제한됩니다.
- Refresh Token:
- 역할: Access Token이 만료되었을 때, 사용자가 다시 로그인하지 않고 새로운 Access Token을 발급받기 위해 사용됩니다.
- 특징: 만료 기간이 Access Token보다 훨씬 깁니다 (예: 7일, 30일). 이 토큰 자체는 API 접근 권한을 가지지 않으며, 오직 Access Token 재발급 용도로만 사용됩니다.
- 보안: 이 토큰이 탈취되면 공격자가 지속적으로 새로운 Access Token을 발급받을 수 있으므로, Access Token보다 훨씬 더 안전하게 저장 및 관리되어야 합니다. 서버는 Refresh Token의 유효성을 검증하고 (보통 DB에 저장된 정보와 비교), 유효하다면 새로운 Access Token(및 경우에 따라 새로운 Refresh Token)을 발급합니다.
7.2 저장 위치 및 보안 전략
Access Token과 Refresh Token은 역할과 민감도가 다르므로, 저장 위치와 보안 전략도 다르게 가져가는 것이 일반적입니다.
- Access Token 저장 위치:
- 메모리 (JavaScript 변수): 브라우저 탭/창이 닫히면 사라지지만, XSS에는 여전히 취약할 수 있습니다. SPA(Single Page Application)에서 상태 관리 라이브러리(Redux, Vuex 등)의 메모리에 저장하는 경우가 많습니다. 새로고침 시 토큰이 유실되므로 Refresh Token으로 재발급 받아야 합니다.
- 세션 스토리지 (Session Storage): 탭/창 단위 저장소. 메모리보다는 유지되지만, 탭/창 종료 시 사라집니다. XSS에는 로컬 스토리지와 마찬가지로 취약합니다.
- 로컬 스토리지 (Local Storage): 브라우저를 닫았다 열어도 유지됩니다. XSS에 취약합니다. 짧은 만료 기간 때문에 비교적 덜 위험하다고 간주될 수도 있지만 여전히 위험성은 존재합니다.
- 쿠키 (보안 속성 없이): 권장되지 않습니다. XSS와 CSRF 모두에 취약합니다.
- Refresh Token 저장 위치 (보안 수준 순):
- HttpOnly 쿠키: 가장 권장되는 방식입니다. HttpOnly, Secure, SameSite=Lax/Strict 속성을 적용하여 XSS와 CSRF 위험을 크게 줄일 수 있습니다. JavaScript에서 접근이 불가능하므로, Access Token 재발급 요청은 서버 API 엔드포인트를 통해 이루어져야 합니다 (예: /refresh_token). 서버는 요청 시 자동으로 전송된 쿠키에서 Refresh Token을 읽어 처리합니다.
- (대안, 덜 일반적) 서버 측 데이터베이스: Refresh Token 자체를 클라이언트에 보내지 않고, 서버 DB에 사용자별로 매핑하여 저장하는 방식입니다. 클라이언트는 최초 로그인 시 받은 세션 ID 쿠키 등으로 Refresh 요청을 보내고, 서버는 해당 사용자의 Refresh Token 유효성을 DB에서 확인합니다. 이는 사실상 세션 방식과 유사해지며 Stateless 장점이 일부 희석됩니다.
- 로컬 스토리지/세션 스토리지: 권장되지 않습니다. XSS 공격으로 Refresh Token이 탈취되면 계정이 완전히 장악될 수 있습니다.
일반적인 흐름:
- 사용자 로그인 성공 시, 서버는 짧은 만료 기간의 Access Token과 긴 만료 기간의 Refresh Token을 발급합니다.
- Access Token은 클라이언트 측(메모리, 세션/로컬 스토리지 등)에 저장하고, Refresh Token은 HttpOnly, Secure, SameSite 쿠키로 설정하여 클라이언트에 전달합니다.
- 클라이언트는 API 요청 시 Access Token을 Authorization: Bearer <token> 헤더에 담아 보냅니다.
- 서버는 Access Token을 검증합니다.
- Access Token이 만료되면 클라이언트는 401 Unauthorized 응답을 받습니다.
- 클라이언트는 /refresh_token과 같은 특정 엔드포인트로 요청을 보냅니다. 이때 브라우저는 자동으로 HttpOnly 쿠키에 담긴 Refresh Token을 함께 전송합니다. (요청 본문에 아무것도 보내지 않거나, CSRF 방지용 토큰 등을 보낼 수 있습니다.)
- 서버는 요청과 함께 온 Refresh Token 쿠키를 확인하고, 서버에 저장된 정보(유효성, 만료 여부 등)와 비교하여 검증합니다. (Refresh Token 자체를 Stateless하게 검증할 수도 있지만, 무효화나 탈취 감지를 위해 서버 측 상태 저장이 권장됩니다.)
- 검증이 성공하면 서버는 새로운 Access Token(및 경우에 따라 새로운 Refresh Token - Refresh Token Rotation)을 발급하여 클라이언트에게 전달합니다.
- 클라이언트는 새로 받은 Access Token으로 다시 API 요청을 시도합니다.
Refresh Token Rotation: Refresh Token을 사용하여 새로운 Access Token을 발급할 때, 기존 Refresh Token을 무효화하고 새로운 Refresh Token을 함께 발급하는 방식입니다. 이는 Refresh Token이 한번 사용되면 폐기되므로, 만약 탈취된 Refresh Token이 사용되더라도 공격자가 계속 사용할 수 없게 만들어 보안성을 높입니다. 구현 복잡성은 증가합니다.
[교수와 제자의 대화]
- 학생: Access Token이 짧고 Refresh Token이 길다는 건 알겠는데, Refresh Token이 왜 더 민감하게 다뤄져야 하는 거죠? 어차피 그걸로는 API 호출 못 한다면서요.
- 교수: 아주 중요한 질문이야. Refresh Token 자체로는 API를 호출할 수 없지만, 그걸 가지고 뭘 할 수 있지? 바로 새로운 Access Token을 계속 발급받을 수 있네. 즉, Refresh Token이 털렸다는 것은 공격자가 원할 때마다 유효한 Access Token을 얻어낼 수 있다는 뜻이야. Access Token이 5분마다 만료된다 해도, 공격자는 5분마다 새 걸 발급받아서 사실상 계정을 영구적으로 장악할 수 있게 되는 걸세. 그래서 Refresh Token의 보관과 관리가 훨씬 더 중요하고 신중해야 하는 거라네.
- 학생: 아... 그래서 HttpOnly 쿠키처럼 최대한 안전한 곳에 두라고 하시는 거군요. Refresh Token Rotation이라는 것도 그런 이유겠네요?
- 교수: 정확하네. Refresh Token Rotation은 한번 사용된 Refresh Token을 바로 무효화시키니까, 만약 공격자가 탈취한 토큰으로 재발급을 시도하는 순간 진짜 사용자가 다음번에 재발급을 시도하면 서버가 이상 징후(이미 사용된 토큰 사용 시도)를 감지하고 해당 사용자의 모든 세션(Refresh Token 포함)을 강제 종료시키는 등의 대응을 할 수 있게 해주지. 보안을 한 단계 더 높이는 전략이라 할 수 있네.
📌 점검:
- "Refresh Token은 Access Token보다 보안에 더 민감하게 다뤄야 합니다. 그 이유는 무엇일까요?"
- 답변 유도: Refresh Token이 탈취되면 공격자가 지속적으로 새로운 유효한 Access Token을 발급받아 계정을 장악할 수 있기 때문입니다.
8. JWT 보안 체크리스트 및 쿠키 옵션 종합
안전한 JWT 기반 인증 시스템을 구축하기 위한 핵심 체크리스트와 쿠키 옵션 설정 예시입니다.
8.1 안전한 쿠키 설정 예시 (Refresh Token 저장용)
Set-Cookie: refreshToken=eyJhbGciOiJIUz...; HttpOnly; Secure; SameSite=Lax; Path=/api/auth; Max-Age=604800; Domain=.example.com
- HttpOnly: JavaScript에서의 접근을 차단하여 XSS 공격으로부터 보호합니다.
- Secure: HTTPS 연결을 통해서만 쿠키가 전송되도록 하여 중간자 공격(MITM) 위험을 줄입니다. (개발 환경에서는 localhost 예외 가능)
- SameSite=Lax: 대부분의 CSRF 공격을 방어하면서 사용자 경험을 크게 해치지 않는 균형 잡힌 설정입니다. 더 강력한 방어가 필요하거나 사이트 특성상 가능하다면 Strict를 고려할 수 있습니다. 외부 도메인의 <iframe> 등에서 사용해야 한다면 None; Secure를 사용하되, CSRF 위험성을 인지하고 추가 대책을 마련해야 합니다.
- Path=/api/auth: 쿠키가 전송될 경로를 제한합니다. 예를 들어, Refresh Token은 Access Token 재발급 API 경로에서만 필요하므로, 해당 경로로 Path를 한정하면 불필요한 요청에 쿠키가 노출되는 것을 줄일 수 있습니다. (사이트 전체에서 필요하다면 / 사용)
- Max-Age=604800: 쿠키의 유효 기간을 초 단위로 설정합니다 (예: 7일). Expires 속성으로 특정 날짜/시간을 지정할 수도 있습니다.
- Domain=.example.com: 쿠키가 적용될 도메인을 지정합니다. 점(.)으로 시작하면 해당 도메인 및 모든 서브도메인에서 쿠키가 유효합니다. 보안을 위해 가능한 한 좁은 범위로 설정하는 것이 좋습니다.
8.2 JWT 보안 강화 체크리스트
- ✅ HTTPS 필수 사용: 모든 통신은 HTTPS를 통해 이루어져야 합니다. Secure 쿠키 옵션도 HTTPS 환경에서만 유효하며, 토큰 자체가 중간에 탈취되는 것을 방지합니다.
- ✅ 강력한 서명 알고리즘 및 키 관리:
- HS256(HMAC SHA256)을 사용한다면, 충분히 길고 예측 불가능한 비밀 키(Secret)를 사용하고 안전하게 보관해야 합니다. 환경 변수나 Secret Manager 등을 활용합니다.
- RSA 또는 ECDSA(비대칭키)를 사용한다면, 개인 키를 안전하게 보호하고 공개 키를 필요한 곳에 적절히 배포해야 합니다. 키 길이는 보안 권고 사항(예: RSA 2048비트 이상)을 따릅니다.
- alg: none은 절대 허용해서는 안 됩니다. 서버 라이브러리에서 해당 옵션을 비활성화해야 합니다.
- ✅ XSS(Cross-Site Scripting) 방어:
- 사용자 입력 값에 대한 철저한 검증 및 이스케이프(Escaping) 처리.
- 프레임워크/라이브러리의 내장 XSS 방어 기능 활용.
- CSP(Content Security Policy) 헤더를 설정하여 신뢰할 수 없는 스크립트 실행을 제한합니다.
- 토큰을 쿠키에 저장 시 HttpOnly 속성 사용.
- ✅ CSRF(Cross-Site Request Forgery) 방어:
- 쿠키 사용 시 SameSite=Lax 또는 Strict 속성 설정.
- SameSite=None 사용 시 Secure 속성 필수 및 추가 CSRF 방어책 고려 (CSRF 토큰, Double Submit Cookie 등).
- 상태를 변경하는 중요한 작업(예: 비밀번호 변경, 글 삭제)은 POST, PUT, DELETE 등 안전한 HTTP 메서드만 사용하고, GET 메서드로 상태를 변경하지 않습니다.
- ✅ 짧은 Access Token 만료 기간: Access Token의 유효 기간은 가능한 한 짧게(수 분 ~ 1시간 이내) 설정하여 탈취 시 피해를 최소화합니다.
- ✅ Refresh Token 보안 강화:
- HttpOnly, Secure, SameSite 쿠키에 저장하는 것을 최우선으로 고려합니다.
- Refresh Token 자체의 유효 기간도 적절히 설정합니다 (수 일 ~ 수 주).
- Refresh Token 탈취 감지 및 무효화 전략을 구현합니다 (예: Refresh Token Rotation, 서버 측 유효성 검증 목록 유지).
- Refresh Token을 사용하여 새 Access Token을 발급할 때 사용자 활동을 모니터링하거나 의심스러운 경우 알림/차단하는 로직을 고려합니다.
- ✅ 철저한 서버 측 토큰 검증:
- 서명(Signature) 검증은 필수입니다.
- 만료 시간(exp) 클레임을 반드시 확인합니다.
- 필요에 따라 발급자(iss), 수신자(aud) 등 다른 등록된 클레임들도 검증하여 토큰이 의도된 목적과 환경에서 사용되는지 확인합니다.
- 발급 시각(iat)이나 유효 시작 시각(nbf) 클레임을 활용하여 토큰의 유효 기간을 더 세밀하게 제어할 수 있습니다.
- 토큰 재사용(Replay Attack) 방지가 필요한 경우, jti(JWT ID) 클레임을 사용하여 한 번 사용된 토큰을 기록하고 중복 사용을 막는 로직을 구현할 수 있습니다 (Stateless 이점 감소).
- ✅ 민감 정보 페이로드 포함 금지: 비밀번호, 개인 식별 정보 등 민감 데이터는 페이로드에 절대 넣지 않습니다.
[교수와 제자의 대화]
- 학생: 체크리스트를 보니 신경 쓸 게 정말 많네요! 단순히 JWT 라이브러리 가져다 쓰는 걸로는 부족하겠어요.
- 교수: 그렇다네. JWT 자체는 표준일 뿐, 그것을 '어떻게 안전하게 사용하는가'는 전적으로 개발자와 시스템 설계자의 책임이지. 특히 토큰 저장 방식(XSS, CSRF 방어)과 Refresh Token 관리 전략은 시스템의 보안 수준을 결정하는 매우 중요한 요소라네.
- 학생: 그럼 이 체크리스트만 잘 지키면 JWT를 안전하게 쓸 수 있다고 봐도 될까요?
- 교수: 이 체크리스트는 안전한 JWT 사용을 위한 좋은 출발점이자 필수적인 항목들이네. 하지만 보안은 끊임없이 변화하는 위협에 대응해야 하는 분야이지. 새로운 공격 기법이 나올 수도 있고, 사용하는 라이브러리나 프레임워크에 취약점이 발견될 수도 있네. 따라서 단순히 체크리스트를 따르는 것을 넘어, 지속적인 보안 업데이트, 코드 검토, 보안 동향 학습이 필요하다네. 보안은 '한 번 설정하고 잊는 것'이 아니라 '지속적으로 관리하고 개선하는 과정'이라는 점을 명심해야 할 걸세.
9. 마무리: JWT, 양날의 검을 제대로 사용하는 법
JWT는 상태 비저장(Stateless) 아키텍처의 확장성과 자가 포함적(Self-contained) 특성 덕분에 현대적인 웹 서비스와 API 환경에서 매우 유용한 인증 및 인가 수단입니다. 서버 부하를 줄이고 마이크로서비스 간 통신을 간결하게 만드는 데 큰 장점을 제공합니다.
하지만 JWT의 편리함에는 보안적인 책임이 따릅니다. 토큰 탈취 시 무효화의 어려움, 페이로드 정보 노출 가능성, 안전한 저장소 선택의 딜레마(XSS vs. CSRF) 등 고려해야 할 사항이 많습니다.
결론적으로, JWT는 "무조건 좋거나 나쁘다"라고 평가할 수 있는 기술이 아닙니다. 시스템의 요구사항(확장성, 보안 수준 등)을 정확히 파악하고, JWT의 장단점과 보안적 함의를 깊이 이해하며, 적절한 보안 대책(짧은 만료 기간, Refresh Token, HttpOnly/Secure/SameSite 쿠키, XSS/CSRF 방어 등)을 철저히 구현할 때 비로소 강력하고 유연한 도구가 될 수 있습니다.
"편리하다"는 이유만으로 JWT를 도입하기 전에, 보안을 얼마나 책임지고 관리할 수 있는지 자문해 보아야 합니다. 올바른 지식과 전략으로 무장한다면, JWT는 여러분의 시스템을 더욱 견고하고 효율적으로 만들어 줄 것입니다.
[최종 점검]
- JWT를 세션과 비교했을 때, 어떤 점에서 이점이 있고 어떤 점에서 취약점이 있을까요?
- JWT의 Payload에는 절대 어떤 정보를 넣으면 안 될까요? 그 이유는 무엇인가요?
- 쿠키의 SameSite 속성 값인 Lax, Strict, None의 주요 차이점을 설명해주세요.
- XSS 공격으로부터 토큰을 보호하기 위해 쿠키에 어떤 속성을 설정해야 하며, CSRF 공격을 방어하기 위해서는 어떤 속성이 중요한가요?
- Access Token과 Refresh Token을 함께 사용하는 주된 이유는 무엇이며, 각각의 보안 전략(만료 시간, 저장 위치)은 어떻게 설계하는 것이 권장되나요?
이상으로 JWT에 대한 심층 분석을 마칩니다. 이 글이 JWT를 이해하고 안전하게 사용하는 데 도움이 되었기를 바랍니다. 궁금한 점이나 추가적인 논의가 필요하시면 언제든지 질문해주세요.
'네트워크 > 네트워크 기초' 카테고리의 다른 글
ARP: IP 주소로 옆자리 친구(MAC 주소) 찾아가기 (0) | 2025.04.08 |
---|---|
3계층 (네트워크) 개요: 다른 동네까지 길 찾아가기 (IP 주소, 라우팅) (0) | 2025.04.08 |
2계층 (데이터 링크): 바로 옆 장비와 통신하는 방법 (이더넷, MAC 주소) (0) | 2025.04.08 |
네트워크 모델와 데이터 단위 (TCP/IP 모델, OSI 7계층, 패킷, PDU) (0) | 2025.04.08 |
HTTPS 완전 정복: TLS, 인증서, CA 그리고 숨겨진 보안 문제들 (0) | 2025.04.03 |