
서명키(HS256 secret 또는 RS256 private key)가 유출되면, ‘순수 JWT 서명검증만’으로는 위조 토큰을 100% 구분할 수 없습니다. 왜냐하면 검증은 “이 키로 서명됐는가”만 보기 때문에, 공격자도 같은 키로 서명하면 정상 토큰과 동일하게 통과합니다.
그래서 “완전한 방지”를 구현하려면, 단일 기법이 아니라 (1) 키 유출 자체를 어렵게 + (2) 유출되더라도 즉시 무력화 + (3) 설령 일부 악용돼도 범위를 제한 + (4) 토큰을 ‘서명만’이 아닌 ‘서버 상태/바인딩’까지 확인하는 방어 심층(Defense-in-Depth) 설계가 필요합니다.
JWT 전체 구조와 동작 흐름 (발급/검증/인가)
1. JWT 구성
header.payload.signature- Header:
alg,kid(키 식별자) 등 - Payload(Claims):
sub,exp,iat,iss,aud,jti,scope/roles등 - Signature: 위변조 방지(서명)
Payload는 Base64URL이라 누구나 디코딩 가능합니다. 개인정보/비밀정보 넣으면 안 됩니다.
2. 발급(생성) 로직 단계
- 사용자 인증 성공(로그인, SSO/OIDC, MFA 등)
- Claims 생성 (최소한으로)
- 필수:
sub,iat,exp - 권장:
iss,aud,jti
- 필수:
- 서명(Sign)
- HS256: 대칭키(secret)
- RS256/ES256: 비대칭키(private로 서명, public으로 검증)
- 전달/저장
- 웹: HttpOnly+Secure+SameSite 쿠키 권장
- API: Authorization Bearer 사용 가능(단 XSS 대비 필수)
3. 검증(Verify) 로직 단계 (중요 순서)
- 토큰 추출(헤더/쿠키)
- 형식 검사(3파트)
- 허용 알고리즘 고정 (
alg=none차단, 기대 alg만 허용) - 서명 검증 (여기서 위변조 판별)
- 클레임 검증
exp만료,nbf(있다면),iss/aud일치
- 인가(Authorization)
roles/scope로 API 권한 확인
문제의 본질: “키 유출 시 위조 JWT가 정상으로 통과” 왜 막기 어렵나
서명검증은 “이 서명이 유효한가?”만 봅니다. 같은 키로 서명하면 공격자 토큰도 진짜 토큰처럼 보입니다.
따라서 유출 이후에도 위조 토큰을 막고 싶다면, 검증 단계에 추가적인 ‘서버가 알고 있는 상태(세션/리보크/토큰버전/바인딩)’ 또는 키를 즉시 폐기/전환할 수 있는 체계가 필요합니다.
“완전하게 구현”에 가까운 실무 보안대책 (권장 아키텍처)
아래는 “키 유출이 발생해도” 외부 위조 토큰을 즉시 무력화하거나 정상 사용자만 계속 사용 가능하게 만드는 설계들입니다. 현실적으로는 A+B+C를 기본, 필요 시 D/E까지 올리는 방식이 가장 안정적입니다.
A. 키 유출 자체를 어렵게: KMS/HSM 기반 서명 + 키 외부반출 금지 (최우선)
핵심
- 서명키를 애플리케이션 서버 메모리/파일/환경변수에 두지 말고
- HSM 또는 클라우드 KMS(키 비내보내기 옵션) 에서 “서명 연산만” 수행
- 앱은 “서명 요청”만 하고, 키 원문은 절대 노출되지 않음
구현 포인트
- (1) Auth 서버가 KMS/HSM에
Sign()요청 → JWT 서명 생성 - (2) 검증 서버는 public key(JWKS)로 검증 (RS256/ES256)
- (3) 키 접근 권한은 최소화(서비스 계정, 네트워크, IAM)
이게 되면 “키 유출” 가능성이 급격히 내려갑니다. (가장 강력)
B. HS256 지양, RS256/ES256 + JWKS + 키 롤링(회전) 체계로 “유출 시 즉시 폐기” 가능하게
왜 대칭키(HS256)가 위험한가
- 검증하는 모든 서비스가 같은 secret을 알아야 함
→ 서비스가 많을수록 유출면 증가
→ 한 곳만 털려도 전체가 무너짐
비대칭키(RS256/ES256) 장점
- private key는 Auth 서버(KMS/HSM)에만
- 다른 서비스는 public key로만 검증
- 검증 서비스가 뚫려도 private 유출이 아님
키 롤링(회전) 필수 요소
- JWT header에
kid포함 - 검증측은 JWKS(공개키 목록) 캐시
- 사고 시
- 유출된
kid키를 JWKS에서 제거(또는 비활성화) - 새 키로 즉시 발급 전환
- 검증은 더 이상 유출키로 서명된 토큰을 인정하지 않음
- 유출된
“유출 후 즉시 차단”이 가능해집니다. (단, 이미 발급된 토큰은 유효기간 동안 위험하므로 아래 C도 같이 필요)
C. “서명검증만으로 통과” 문제를 근본적으로 막는 핵심: 서버 상태 검증(리보크/세션/토큰버전)
키가 유출되면 공격자는 마음대로 sub, roles까지 넣어 서명할 수 있습니다.
이를 막으려면 “토큰이 서명만 맞으면 끝”이 아니라, 서버가 가진 상태와 교차검증해야 합니다.
C-1) Access Token은 짧게 + Refresh Token은 서버 저장 + 회전(필수에 가까움)
- Access: 5~15분
- Refresh: 7~30일 (하지만 DB/Redis에 저장)
- Refresh 사용 시
- 새 refresh 발급(회전) + 기존 refresh 폐기
- 재사용 감지 시 전체 세션 폐기
키 유출이 있어도 공격자가 access를 계속 무한 발급하려면 refresh까지 필요하게 만들고, refresh는 서버에서 통제합니다.
C-2) jti 기반 “토큰 허용 목록/차단 목록(리보크)” 검증
- 발급 시
jti(UUID) 생성 - 서버(예: Redis)에
jti를 저장하고 TTL=exp까지 유지 - 요청마다
- 서명검증 + exp 검증 + jti 존재/상태 확인
- 강제 로그아웃/계정 정지 시
- 해당 사용자 jti들을 제거/차단
엄밀히 말하면 이 방식은 JWT를 “세션처럼” 만듭니다. 대신 유출키 위조 토큰은 jti가 서버에 없으니 차단됩니다.
“서명키 유출로 만든 임의 토큰”이 검증을 통과하지 못하게 만드는 가장 직관적인 방법입니다.
C-3) 사용자 단위 token_version(또는 session_version) 클레임 + DB 비교
- 사용자 테이블에
token_version숫자 저장 - 발급 시 payload에
tv포함 - 검증 시 DB의 tv와 일치해야 통과
- 사고/로그아웃/비번변경/권한변경 시 tv 증가 → 기존 토큰 전부 무효화
운영이 쉽고 강력합니다. 특히 “권한 변경”과도 잘 맞습니다.
권장 조합(실무 베스트)
- Access 짧게 + Refresh 회전(C-1) 기본
- 민감 API(결제/관리자/권한변경)는 tv 또는 jti 체크(C-2/C-3) 추가
D. 토큰 “바인딩”: 탈취/위조 토큰이 다른 환경에서 못 쓰게 묶기 (고급 방어)
키 유출뿐 아니라 “토큰 탈취”에도 강합니다. 키 유출 상황에서도 “바인딩 정보”를 서버가 검증하면 공격자 토큰을 차단할 여지가 생깁니다.
D-1) mTLS(Client Certificate) 바인딩
- 내부 관리 API나 B2B 고신뢰 환경에서 강력
- 토큰에
cnf(confirmation) 넣고, 요청의 클라이언트 인증서 지문과 매칭
D-2) DPoP(Proof of Possession) / 서명된 요청 바인딩
- 요청마다 클라이언트가 별도 키로 proof 서명
- 토큰의
cnf에 public key를 넣고 검증 - 공격자가 JWT만 만들어도 요청 proof까지 맞춰야 함(난이도 상승)
웹 브라우저 일반 환경에서는 적용 난이도가 있으나, 보안 요구가 높으면 검토 가치가 큽니다.
E. “완전 차단”에 가장 가까운 선택지: JWT(자가검증) 대신 ‘불투명 토큰(opaque)’ + 인트로스펙션
정말 “키 유출에도 JWT 위조를 구조적으로 막고 싶다”면,
- 클라이언트는 랜덤 문자열(opaque token)만 들고 다님
- 서버는 매 요청마다 토큰을 DB/캐시에서 조회해 유효성/권한을 판단
이 방식은 “키 서명”이 아니라 “서버 상태”가 진실입니다.
서명키 유출로 토큰 위조한다는 개념 자체가 사라집니다.
실무에서는 보통
- 일반 API: JWT(성능/분산)
- 고위험 API: opaque 또는 introspection 혼합
이런 “하이브리드”로 갑니다.
사고 시나리오별 “즉시 대응” 플레이북 (유출 후 완전성에 핵심)
키 유출은 “언제든 가능한 전제”로 보고, 탐지-차단-복구가 자동화돼야 합니다.
유출 의심/확정 시 즉시 조치
- 키 롤링(새 키 생성)
- JWKS에서 유출
kid제거(검증 불가하게) - Refresh 토큰 전량 폐기(또는 token_version 증가)
- 관리자/고위험 계정 세션 강제 종료
- 이상 요청 탐지 규칙 강화(아래 6절)
“이미 발급된 유효 토큰” 처리
- Access 토큰을 짧게 해두면 피해 시간 창이 작아짐
- token_version/jti 기반이면 즉시 무효화 가능
- opaque/introspection면 즉시 차단 가능
“완전 구현”에 가까운 권장 설계안 (현실적인 최강 조합)
요구하신 조건(키 유출 + 외부 위조 JWT 생성)까지 고려하면, 아래 조합이 “현실적으로 가장 완전”에 가깝습니다.
권장 레벨 1 (대부분 조직의 표준 베스트)
- RS256/ES256 (private는 Auth 서버/KMS/HSM)
- JWKS + kid + 키 롤링 체계
- Access 10~15분
- Refresh는 서버 저장 + 회전 + 재사용 탐지
- iss/aud/exp 필수 검증 + alg allowlist 고정
권장 레벨 2 (유출·위조까지 강하게 막기)
- 위 레벨1 + token_version(DB) 검증
- 고위험 API는 tv 체크 필수
- 권한 변경 시 tv 증가로 즉시 무효화
권장 레벨 3 (정말 “완전 차단” 성향)
- 레벨2 + 고위험 API는 opaque token 또는 introspection
- 또는 mTLS/DPoP 바인딩 적용(가능한 환경에서)
운영 보안 관점 점검포인트
발급 정책
- Access exp ≤ 15분 (가능하면 5~10분)
- Refresh는 서버 저장 + 회전 + 재사용 탐지
- roles/scope는 최소화(특히 admin은 별도 토큰/별도 인증 권장)
- iss/aud/sub/jti/iat 포함 및 필수 요구
검증 정책
- alg allowlist 고정(서버가 기대한 alg만)
- iss/aud 검증 강제
- kid는 “허용된 키셋”에서만 조회(임의 URL/kid 기반 fetch 금지)
- (선택) tv 또는 jti 기반 서버 상태 검증 적용
- clock skew leeway는 최소(예: 30~60초)
키 관리
- private/secret 키는 KMS/HSM(비내보내기)
- 키 접근은 최소 권한/IAM, 네트워크 제한
- 키 롤링 자동화 + 사고 시 즉시 폐기 절차
- 키 사용/서명 호출 감사로그 확보
탐지/모니터링(키 유출 조기 발견 핵심)
- 동일 sub로 다른 ASN/국가에서 짧은 시간 내 다중 사용
- 비정상 scope 상승, admin API 과다 호출
- 실패 검증(iss/aud 불일치, kid 미존재) 급증
- refresh 재사용 탐지 시 즉시 계정 잠금/세션 폐기
“서버 상태 검증(jti/토큰버전)” 예시 구현 (Python, 개념 코드)
아래는 “키 유출로 만든 임의 JWT”가 서명은 맞아도 통과하지 못하게 만드는 핵심 패턴입니다.
(1) token_version 방식(권장, 단순/강력)
- DB에
token_version저장 - JWT에
tv넣기 - 검증 시 DB와 비교
import time, jwt
ISSUER="auth"
AUD="api"
SECRET="...HS256 예시(운영은 RS256 권장)"
# 예시 DB
USER_DB = {
"user-1": {"token_version": 3, "roles": ["user"]},
}
def issue(user_id: str):
now = int(time.time())
tv = USER_DB[user_id]["token_version"]
payload = {
"sub": user_id,
"tv": tv,
"iss": ISSUER,
"aud": AUD,
"iat": now,
"exp": now + 600,
}
return jwt.encode(payload, SECRET, algorithm="HS256")
def verify(token: str):
payload = jwt.decode(
token, SECRET,
algorithms=["HS256"],
issuer=ISSUER, audience=AUD,
options={"require": ["exp","iat","sub"]}
)
user = USER_DB.get(payload["sub"])
if not user:
raise Exception("unknown user")
if payload.get("tv") != user["token_version"]:
raise Exception("token revoked (token_version mismatch)")
return payload
def revoke_all_sessions(user_id: str):
USER_DB[user_id]["token_version"] += 1
- 키가 유출돼도, 공격자가
tv값을 모르면?
→ 모르면 실패 - 공격자가 tv를 안다고 가정해도(=DB까지 유출)?
→ 그건 이미 “키 유출”을 넘어 “DB 유출” 사고라 대응 범위가 커집니다.
→ 이 경우에도 refresh 회전/세션제어/추가 바인딩/모니터링이 필요합니다.
(2) jti allowlist(또는 denylist) 방식(가장 직접적으로 위조 차단)
- 발급 시
jti를 Redis에 저장(TTL=exp) - 검증 시 Redis에
jti존재해야 통과
개념적으로
- “서명검증 + exp 통과” AND “서버가 발급한 jti 맞음”
→ 키 유출로 임의 생성한 토큰은 jti가 서버에 없으니 차단
키 유출 + 외부 위조 JWT까지 포함한 “완전한” 방어의 정답
서명키 유출이 나도 위조 JWT가 검증 통과하지 못하게 하려면, 아래 중 하나(또는 조합)가 필수입니다.
- 키 유출을 거의 불가능하게: KMS/HSM 비내보내기 + 서명 연산만 허용
- 유출되면 즉시 무력화: RS256/ES256 + JWKS + kid + 키 롤링/폐기 자동화
- 서명검증만으로 끝내지 않기(핵심)
- token_version(DB 비교) 또는 jti(캐시 조회)로 서버 상태 검증 추가
- 고위험 구간은 구조적으로 차단: opaque token/introspection 또는 mTLS/DPoP 바인딩
권장 레벨2(키 롤링 + refresh 회전 + token_version) 기준의 표준 설계도
- 발급 API / 갱신 API / 로그아웃 API
- Redis/DB 스키마(세션, refresh, jti, token_version)
- 키 롤링(JWKS) 배포 방식
- 탐지 룰(이상 징후)
댓글