본문 바로가기
프로그램 (PHP,Python)

Stateless 함정: JWT 서명키 유출과 위조 방어 위한 안전한 인증 심층 설계

by 날으는물고기 2026. 2. 15.

Stateless 함정: JWT 서명키 유출과 위조 방어 위한 안전한 인증 심층 설계

728x90

서명키(HS256 secret 또는 RS256 private key)가 유출되면, ‘순수 JWT 서명검증만’으로는 위조 토큰을 100% 구분할 수 없습니다. 왜냐하면 검증은 “이 키로 서명됐는가”만 보기 때문에, 공격자도 같은 키로 서명하면 정상 토큰과 동일하게 통과합니다.

300x250

그래서 “완전한 방지”를 구현하려면, 단일 기법이 아니라 (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. 발급(생성) 로직 단계

  1. 사용자 인증 성공(로그인, SSO/OIDC, MFA 등)
  2. Claims 생성 (최소한으로)
    • 필수: sub, iat, exp
    • 권장: iss, aud, jti
  3. 서명(Sign)
    • HS256: 대칭키(secret)
    • RS256/ES256: 비대칭키(private로 서명, public으로 검증)
  4. 전달/저장
    • 웹: HttpOnly+Secure+SameSite 쿠키 권장
    • API: Authorization Bearer 사용 가능(단 XSS 대비 필수)

3. 검증(Verify) 로직 단계 (중요 순서)

  1. 토큰 추출(헤더/쿠키)
  2. 형식 검사(3파트)
  3. 허용 알고리즘 고정 (alg=none 차단, 기대 alg만 허용)
  4. 서명 검증 (여기서 위변조 판별)
  5. 클레임 검증
    • exp 만료, nbf(있다면), iss/aud 일치
  6. 인가(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 혼합
    이런 “하이브리드”로 갑니다.

사고 시나리오별 “즉시 대응” 플레이북 (유출 후 완전성에 핵심)

키 유출은 “언제든 가능한 전제”로 보고, 탐지-차단-복구가 자동화돼야 합니다.

유출 의심/확정 시 즉시 조치

  1. 키 롤링(새 키 생성)
  2. JWKS에서 유출 kid 제거(검증 불가하게)
  3. Refresh 토큰 전량 폐기(또는 token_version 증가)
  4. 관리자/고위험 계정 세션 강제 종료
  5. 이상 요청 탐지 규칙 강화(아래 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가 검증 통과하지 못하게 하려면, 아래 중 하나(또는 조합)가 필수입니다.

  1. 키 유출을 거의 불가능하게: KMS/HSM 비내보내기 + 서명 연산만 허용
  2. 유출되면 즉시 무력화: RS256/ES256 + JWKS + kid + 키 롤링/폐기 자동화
  3. 서명검증만으로 끝내지 않기(핵심)
    • token_version(DB 비교) 또는 jti(캐시 조회)로 서버 상태 검증 추가
  4. 고위험 구간은 구조적으로 차단: opaque token/introspection 또는 mTLS/DPoP 바인딩

권장 레벨2(키 롤링 + refresh 회전 + token_version) 기준의 표준 설계도

  • 발급 API / 갱신 API / 로그아웃 API
  • Redis/DB 스키마(세션, refresh, jti, token_version)
  • 키 롤링(JWKS) 배포 방식
  • 탐지 룰(이상 징후)
728x90
그리드형(광고전용)

댓글