보안

[보안] JWT 보안 - 흔히 하는 실수 5가지

jmineekim 2026. 5. 14. 10:00

예전에 신입한테 JWT 인증 좀 붙여보라고 시켰는데, 그 친구가 토큰을 들고 와서 "이거 base64로 까보니까 페이로드가 그냥 다 보이는데요?" 하더라고요. 맞습니다. JWT는 암호화가 아니라 서명입니다. 그리고 그 서명 검증 한 줄을 살짝만 잘못 짜도, 누구나 admin 토큰을 위조해서 서버 안방까지 들어옵니다.

실제로 2018년 이후 OWASP나 CVE에 올라온 인증 관련 취약점 중 상당수가 JWT 라이브러리 또는 잘못된 사용 패턴에서 비롯됐습니다. 무서운 건 이게 어려운 공격이 아니라는 점이에요. 토큰 한 줄 까보고 alg만 바꾸는, 정말 단순한 조작으로도 인증을 뚫는 경우가 흔합니다. 오늘은 실무에서 가장 자주 보는 JWT 실수 다섯 가지를, 위험한 코드와 안전한 코드로 같이 정리해 볼게요.

개념 / 원리

JWT는 점(.)으로 구분된 세 부분으로 이루어진 문자열입니다.

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRtaW4ifQ.signature
   Header              Payload                 Signature

각 부분은 그냥 base64url 인코딩이라서 누구나 디코딩해서 내용을 볼 수 있습니다. jwt.io 같은 사이트에 토큰을 붙여넣으면 페이로드가 바로 펼쳐지는 것도 그래서예요. 위변조를 막아주는 건 오직 마지막 Signature 하나뿐입니다. HS256은 대칭키(비밀키 한 개로 서명·검증), RS256은 비대칭키(개인키로 서명, 공개키로 검증)를 씁니다.

한 줄로 정리하면: JWT의 보안은 "서명 검증을 제대로 했느냐"에 100% 달려 있습니다.

세션 방식과 비교하면 차이가 더 명확합니다. 세션은 서버가 세션 ID를 키로 들고 있고 클라이언트는 ID만 갖고 다닙니다. JWT는 반대로 서버가 상태를 안 들고, 토큰 안에 사용자 정보를 그대로 담아 보냅니다. 그래서 stateless라 확장에 좋지만, 한 번 발급된 토큰을 즉시 무효화하기는 어렵다는 트레이드오프가 있어요.

실전 예제

아래 다섯 가지 실수는 코드와 함께 보겠습니다. Python PyJWT 기준이지만 다른 언어에서도 패턴은 동일합니다.

1. alg: none 수용

가장 유명한 JWT 취약점입니다. JWT 스펙은 "서명 없음"을 의미하는 none 알고리즘을 허용합니다. 공격자가 Header의 alg"none"으로 바꾸고 서명 부분을 비워서 보내면, 일부 라이브러리는 서명 검증을 그냥 건너뜁니다. 페이로드만 admin으로 바꿔도 통과되는 거죠.

# 취약: 토큰의 alg를 그대로 신뢰
import jwt
payload = jwt.decode(user_token, key='', algorithms=['none', 'HS256'])
# 안전: 화이트리스트로 명시. none 절대 금지
payload = jwt.decode(user_token, SECRET, algorithms=['HS256'])

2. 알고리즘 혼동(Algorithm Confusion)

서버가 RS256(공개키 기반)을 쓴다고 합시다. 공개키는 말 그대로 공개돼 있고, 공격자도 받아볼 수 있어요. 공격자가 algHS256으로 바꾸고, 서버의 공개키 문자열을 HMAC 비밀키로 써서 서명한 토큰을 보냅니다. 검증 시 알고리즘을 토큰의 alg에서 가져다 쓰면, 라이브러리는 "HS256으로 검증하라"는 지시를 따라 공개키를 HMAC 키로 써서 검증을 시도하고… 통과시켜 버립니다. 비대칭키 시스템이 한순간에 대칭키 시스템으로 둔갑하는 거죠.

# 취약: algorithms 미지정
jwt.decode(token, public_key)
# 안전: 검증 알고리즘을 코드에 박아둔다
jwt.decode(token, public_key, algorithms=['RS256'])

3. 약한 비밀키 / 하드코딩

HS256은 비밀키 강도가 전부입니다. "secret", "myapp2024" 같은 키는 GPU로 몇 분이면 깨집니다. 깃허브에 비밀키가 그대로 커밋되어 있는 경우도 의외로 자주 보이는데, 한 번 공개 저장소에 올라간 키는 즉시 폐기하고 새로 발급해야 합니다(git history에서 지워도 이미 클론된 것까지는 못 막아요).

SECRET = "secret"  # 사전 공격에 무방비
import secrets
# 안전: 64바이트 랜덤 + 환경변수로 주입
SECRET = os.environ["JWT_SECRET"]  # secrets.token_urlsafe(64)로 생성

4. exp(만료) 미설정

만료가 없으면 한번 유출된 토큰은 평생 유효합니다.

# 취약: 만료 없음
token = jwt.encode({"user_id": 123}, SECRET, algorithm='HS256')
# 안전: access는 짧게, refresh는 별도로
from datetime import datetime, timedelta
token = jwt.encode({
    "user_id": 123,
    "iat": datetime.utcnow(),
    "exp": datetime.utcnow() + timedelta(minutes=15),
}, SECRET, algorithm='HS256')

Access Token은 15분1시간, Refresh Token은 730일로 분리하는 게 일반적입니다.

5. 페이로드에 민감 정보

페이로드는 base64만 디코딩하면 누구나 보입니다. 저도 예전에 신입이 짠 코드 리뷰하다가 페이로드에 사용자 이메일과 전화번호가 그대로 들어가 있는 걸 보고 깜짝 놀란 적이 있어요. 로그에 토큰이 한 줄만 남아도 개인정보 유출 사고가 됩니다.

# 절대 금지
payload = {"user_id": 123, "password": "1234", "ssn": "900101-1234567"}

비밀번호, 주민번호, 결제정보 등은 JWT에 절대 넣지 마세요. 정 필요하면 서버 DB에서 user_id로 조회합니다.

주의할 점

  • 토큰 폐기(revocation): JWT는 stateless라 발급한 토큰을 즉시 막을 방법이 없습니다. 강제 로그아웃이 필요하면 blacklist 캐시(Redis)나 짧은 access + refresh 패턴이 현실적입니다.
  • 저장 위치: 브라우저의 localStorage는 XSS 한 방에 토큰이 다 털립니다. HttpOnly + Secure + SameSite=Strict 쿠키가 더 안전합니다.
  • 라이브러리 최신화: PyJWT, jjwt 등은 과거에 알려진 취약점이 있었습니다. 가급적 최신 메이저 버전으로.
  • kid 헤더 주입: kid(key id)를 SQL이나 파일 경로에 그대로 넣으면 SQL Injection / Path Traversal이 됩니다. 화이트리스트로만 검증하세요.
  • 시계 어긋남(clock skew): 여러 서버에서 토큰을 발급·검증하면 NTP가 살짝만 어긋나도 exp 검증이 실패합니다. 라이브러리의 leeway 옵션으로 보통 30~60초 정도 여유를 주는 편이 안전합니다.
  • aud, iss 검증: 토큰이 이 서비스용으로 발급된 게 맞는지(aud), 우리가 신뢰하는 발급자(iss)인지도 함께 검증해야 합니다. 다른 서비스용 토큰이 우리 API에서 그대로 통과하면 곤란하니까요.

마무리

정리하면 세 줄입니다.

  • 서명 검증은 알고리즘 화이트리스트를 코드에 박아두기. alg:none과 algorithm confusion은 이 한 줄로 거의 막힌다.
  • 비밀키는 충분히 긴 랜덤값을 환경변수로 주입하고, exp로 만료를 반드시 설정한다.
  • 페이로드는 누구나 본다는 가정으로 작성. 민감 정보는 절대 넣지 않는다.
반응형