JWT TTL 만료 시간 보안 설계: Access/Refresh 토큰 수명과 Jitter 실전 적용

목차

Before: Access 24시간 / Refresh 30일

즉, 작년까지 운영하던 내부 어드민의 JWT TTL 만료 시간 보안 설정은 단순했다. Access 24시간, Refresh 30일. 로그인 한 번 하면 한 달은 로그인 창을 볼 일이 없었으니 UX 민원은 없었다.

예를 들어, 다만 토큰 탈취 의심 로그가 올라왔을 때 할 수 있는 게 별로 없었다. 의심이 잡혀도 그 Access는 앞으로 최대 24시간 유효하다. Refresh는 30일이다. 서버가 그 토큰을 강제로 죽이려면 blacklist를 DB에 올리거나, 서명 키 자체를 롤링하거나, 사용자 전체를 강제 로그아웃시켜야 했다. 세 선택지 다 비용이 컸다.

stateless의 장점이 바로 단점이 되는 구간이다. "서명만 맞으면 유효"라는 규칙을 깨는 순간 JWT를 쓸 이유가 사라진다.

After: Access 5분 / Refresh 1일 + Rotation + Jitter

지금은 Access 5분, Refresh 1일, Refresh Rotation을 매 갱신마다 수행한다. 여기에 ±30초 Jitter를 만료에 붙였다.

그러나, 탈취 대응 윈도우는 24시간에서 5분으로 줄었다. 갱신 트래픽이 정각마다 몰리는 현상도 사라졌다. 갱신 RPS는 올랐지만 인증 서버 CPU 증가분은 평균 8%p 수준이었다. 체감상 덜 아팠다.

예를 들어, 과정은 길었다. 조합 세 개를 비교했고, Jitter 폭을 결정했고, Refresh Rotation에서 race condition을 한 번 만났다. 아래는 그 기록이다.

비교 기준을 먼저 정했다

TTL 논의는 "짧을수록 안전하다"에서 끝나면 숫자가 안 나온다. 회의 두 번 하고 축 네 개를 고정했다.

  • 보안 윈도우: 토큰 탈취 후 서버 개입 없이 자연 만료까지 걸리는 최대 시간. 짧을수록 좋다.
  • 갱신 부하: 분당 Access 갱신 요청 수. 100만 DAU 기준으로 환산해 인증 서버와 Redis에 가해지는 RPS를 계산했다.
  • UX 마찰: Refresh 만료로 재로그인이 요구되는 빈도. 모바일 앱 백그라운드 복귀 시나리오까지 같이 봤다.
  • 구현 복잡도: Rotation, blacklist, jitter, grace window 각 기능의 코드/운영 비용.

그러나, 네 축의 가중치가 같진 않다. 내부 어드민이라 UX 마찰을 약간 양보해도 됐고, 보안 윈도우를 우선했다. 일반 소비자용이면 반대로 기울었을 것이다.

OWASP의 JWT Cheat Sheet와 Auth0의 Token Lifetime 문서도 대체로 같은 축을 공유한다 (출처: OWASP JWT for Java Cheat Sheet 2025-02 업데이트, Auth0 Docs "Token Lifetime" 2025년 기준).

후보 세 개 비교

또한, 검토한 조합은 세 개였다.

항목 A: 15분 / 7일 B: 5분 / 1일 + Rotation C: 1분 / 1시간 + Rotation + Jitter
Access TTL 15분 5분 1분
Refresh TTL 7일 1일 1시간
보안 윈도우 15분 5분 1분
환산 갱신 RPS (100만 DAU) ~1,100 ~3,300 ~16,600
재로그인 빈도 주 1회 일 1회 시간마다
구현 비용 낮음 중간 높음

갱신 RPS는 평균 사용자 활동 시간을 하루 2시간으로 잡고, Access 수명 동안 평균 한 번 갱신한다고 단순화한 값이다.

실제로, A는 기존보다 확실히 낫지만 Refresh 7일이 걸렸다. 30일에서 7일로 줄였다 해도 Refresh가 탈취되면 일주일 내내 새 Access를 뽑아낼 수 있다. Rotation 없이는 탐지 수단이 없다.

결국, C는 계산상 가장 안전하지만 현실성이 떨어졌다. 모바일 앱에서 백그라운드 5분만 있다가 돌아와도 Refresh까지 만료된 상태가 잦았다. 1시간 Refresh는 사용자가 앱 닫고 점심 먹고 오면 로그인 창을 본다는 뜻이다.

또한, B로 갔다. Rotation과 Jitter를 얹어 보안 윈도우는 5분을 유지하되, 탈취된 Refresh를 탐지할 수 있게 했다.

Jitter를 왜, 어떻게 붙였나

피크 만료 몰림 현상

게다가, 로그인 피크(예: 공지 푸시 직후) 구간에 한꺼번에 발급된 Access 토큰은 정확히 TTL 뒤 같은 초에 만료된다. 5분 TTL이면 5분 뒤 그 초에 수천 개 갱신 요청이 몰린다. 초당 요청이 평균의 6~7배까지 올라간 로그가 찍혔다. 인증 서버 CPU가 튀고 뒤따라 Redis도 튀었다.

해결은 TTL에 난수를 더하는 거다. 코드 세 줄이면 된다.

구현: secrets 모듈 사용

import secrets
from datetime import datetime, timedelta, timezone

ACCESS_TTL_SECONDS = 300   # 5분
JITTER_RANGE = 30          # ±30초

def make_access_exp() -> datetime:
    # 발급 시점에 ±30초 난수를 더해 피크 만료 몰림을 분산
    jitter = secrets.randbelow(JITTER_RANGE * 2 + 1) - JITTER_RANGE
    ttl = ACCESS_TTL_SECONDS + jitter
    return datetime.now(timezone.utc) + timedelta(seconds=ttl)

def issue_access_token(user_id: str) -> str:
    payload = {
        "sub": user_id,
        "iat": datetime.now(timezone.utc),
        "exp": make_access_exp(),
        "typ": "access",
    }
    return jwt.encode(payload, SECRET, algorithm="HS256")

random 대신 secrets를 쓴 건 예측 불가능성 때문이다. random은 시드가 같으면 같은 값을 뱉는다. 실제 공격 시나리오는 제한적이지만, 인증 도메인에서는 기본을 CSPRNG로 깔고 가는 편이 마음이 편하다.

Jitter 폭을 얼마로 할지

±30초는 임의 값이 아니다. 피크 요청이 몰리는 구간이 대체로 1초 단위라, ±30초면 60초 폭에 분산된다. 지표상 피크/평균 비율이 6.8배에서 1.3배로 떨어졌다. ±10초만 줘도 평탄화 효과는 보이지만, 여유를 뒀다.

예를 들어, Refresh에도 Jitter를 줘야 하나? Refresh는 발급 시점이 이미 분산돼 있어 실익이 적다고 판단해 뺐다.

Refresh Rotation에서 만난 race condition

Refresh Rotation은 Refresh 토큰을 쓸 때마다 새 Refresh를 발급하고 이전 것을 무효화하는 방식이다. 탈취된 Refresh가 다시 쓰이면 사용 흔적이 남아 감지 가능하다 (OAuth 2.0 Refresh Token Rotation, RFC 6749 확장 관행).

즉, 구현 직후 오탐이 터졌다.

첫 사고: 멀티탭 동시 갱신

같은 계정으로 데스크톱 탭 두 개를 띄운 사용자였다. 두 탭이 동시에 Access 만료를 감지하고 같은 Refresh를 제출했다. 첫 요청은 성공하고 새 Refresh가 발급됐다. 두 번째 요청은 이미 무효화된 Refresh를 들고 와서 "재사용 의심"으로 분류, 세션 전체가 강제 로그아웃됐다.

로그는 이렇게 남았다.

[refresh] t=10:23:01.104 token=r_a1b2 used ok, issue=r_c3d4 uid=12345
[refresh] t=10:23:01.112 token=r_a1b2 REUSED_DETECTED uid=12345
[auth]    t=10:23:01.115 uid=12345 all_sessions_revoked reason=reuse

8밀리초 차이. 탈취가 아니라 UI 동시성 문제였는데 시스템은 최악을 가정하고 세션을 날렸다.

해결 1: 짧은 grace window

Redis에 "이전 Refresh → 새 Refresh" 매핑을 짧은 TTL(2초)로 저장했다. grace window 안에 같은 이전 토큰으로 재요청이 오면 동일한 새 Refresh를 그대로 리턴한다. idempotent 응답이다.

def rotate_refresh(old_token: str) -> str:
    # grace window 내 재요청이면 동일한 새 토큰 반환 (idempotent)
    cached = redis.get(f"rotation:grace:{old_token}")
    if cached:
        return cached.decode()

    if not is_valid_refresh(old_token):
        # 이미 쓰였고 grace window도 지났다 → 진짜 재사용 의심
        revoke_all_sessions(get_user_id(old_token))
        raise ReusedRefreshError()

    new_token = issue_refresh_token(get_user_id(old_token))
    invalidate(old_token)
    redis.setex(f"rotation:grace:{old_token}", 2, new_token)
    return new_token

게다가, 2초 창은 타협 값이다. 너무 짧으면 오탐이 남고, 너무 길면 실제 재사용 탐지가 무뎌진다. 운영 로그를 보니 오탐의 대부분이 100ms 이내에 몰렸고, 2초면 99% 이상 잡혔다.

해결 2: 클라이언트 락 (보조)

브라우저에서 동일 기기의 여러 탭이 동시에 Refresh를 시도하지 않도록 navigator.locks 또는 BroadcastChannel로 mutex를 걸었다. 먼저 획득한 탭만 네트워크 요청을 보내고, 나머지는 결과를 공유받는다. 클라이언트 락은 실패해도 grace window가 잡아주니 보조 수단이다.

게다가, 전환 후 주당 Refresh 재사용 탐지 이벤트는 0~3건 수준으로 떨어졌다. 이전 구현에서는 수십 건이 찍혔고 대부분이 오탐이었다.

운영 체감: 숫자와 함정

물론, 실측 환경은 인증 서버 인스턴스 2대(t3.medium 상당), Redis Cluster 3노드, 일 활성 사용자 약 12만이다.

숫자로 본 변화

지표 전환 전 (24h / 30d) 전환 후 (5m+jitter / 1d+rotation)
갱신 RPS 평균 3 220
갱신 RPS 피크 12 420
인증 서버 CPU 평균 18% 26%
Redis 메모리 240MB 315MB
Refresh 재사용 탐지/주 해당 없음 0~3건

CPU 8%p 정도 올랐지만 트래픽 대비 의외로 얕은 증가였다. JWT 검증은 서명 검사 비용이 대부분이고, Redis 왕복이 한 번 추가됐을 뿐이다. 오히려 Refresh Rotation의 오탐 튜닝이 인프라 부담보다 훨씬 오래 걸렸다.

예상 못 했던 구간

  • 리프레시 직후 API 호출 실패: 새 Access를 받기 전에 이미 나간 요청들이 401로 떨어졌다. 클라이언트에서 401을 받으면 Refresh 후 원래 요청을 재시도하는 인터셉터로 처리했다. 이 인터셉터가 없으면 UX가 지저분해진다.
  • 시간 동기 문제: 서버와 클라이언트 시계 차이가 몇 초만 돼도 Access가 즉시 만료된 것처럼 보인다. 검증 시 leeway를 5초 줬다 (PyJWT leeway 옵션, 공식 문서 2025년 기준).
  • 로그 노이즈: Refresh 엔드포인트 호출이 늘면서 access log가 커졌다. 샘플링 규칙을 조정했다.

아직 못 푼 것

반면, Refresh 탈취가 실제로 일어났을 때 재사용 탐지 후 어디까지 revoke할지는 여전히 고민이다. 해당 유저의 전체 세션을 날리는 게 가장 안전하지만 UX는 나쁘다. 기기 단위 session family로 제한하는 방안을 실험 중이지만 아직 안정적인 경계선을 못 찾았다.

당장 바꿀 것과 다음에 볼 것

거기에, 운영 중인 서비스에서 이번 주 안에 적용할 수 있는 건 세 개다.

  1. Access TTL을 분 단위로 재설정. 24시간을 쓰고 있다면 15분부터. 비즈니스 영향 관찰 후 5분까지 당긴다.
  2. 발급 시점에 ±30초 Jitter. 코드 세 줄이고 피크 만료 몰림이 즉시 사라진다. 서명 키나 기존 토큰 포맷에 영향 없다.
  3. Refresh Rotation은 grace window 설계부터. Rotation부터 켜면 멀티탭 오탐으로 CS가 터진다. 2초짜리 idempotent 응답 로직을 먼저 붙이고 Rotation을 활성화하자.

다음엔 DPoP(Demonstrating Proof-of-Possession, RFC 9449)로 토큰을 기기 키에 바인딩하는 방식을 PoC로 돌려볼 생각이다.

관련 글