C.W.K.
Stream
Lesson 03 of 06 · published

JWT & Refresh — Format, claim, refresh 춤

~12 min · auth-security, jwt, claims, refresh

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"JWT 는 signature 가진 base64. 그게 다야. 냅킨 위에서 손으로 JWT decode 할 수 있으면 그 mystique 사라져 — 보안 모델 명확해져: 누구든 claim 읽음, signing key 보유자만 유효한 거 발급."

3 부분 format

JWT 는 dot 으로 합쳐진 base64url-encoded 부분 셋: HEADER.PAYLOAD.SIGNATURE. 각 부분이 JSON 객체 (header 와 payload) 혹은 binary signature, 전체가 HTTP header 에 맞도록 encode.

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1XzQyIiwiZXhwIjoxNzYwMDAwMDAwfQ.signature_bytes

Header: {"alg":"HS256","typ":"JWT"} — algorithm + type. Payload: {"sub":"u_42","exp":1760000000} — claim (subject + expiry). Signature: 발급자 secret/private key 쓴 base64(header) + "." + base64(payload) 의 HMAC-SHA256 이나 RSA signature.

누구든 JWT 복사, 첫 두 부분 base64-decode, claim 읽을 수 있어. Signature 가 signing key 가진 누군가가 token 발급했고 변경 안 됐다 증명. JWT 는 서명, 암호화 아냐. Payload 에 secret 넣는 게 top 5 실수.

표준 claim (왜 쓰나)

RFC 7519 가 3-글자 claim 이름 한 줌 예약:

  • iss (issuer) — token mint 한 사람. Client 가 맞는 signing key 신뢰.
  • sub (subject) — token 표현하는 누구/뭐 (보통 user ID).
  • aud (audience) — token 의 대상. 한 issuer 가 여러 service cross-contamination 없이 서빙.
  • exp (expiry) — Unix timestamp, 그 후 token 무효. 항상 포함.
  • nbf (not before) — Unix timestamp, 그 전 token 무효. 드문; 예약 접근에 유용.
  • iat (issued at) — mint Unix timestamp. Audit + grace window 에 유용.
  • jti (JWT ID) — unique ID. Revocation 지원 원하면 필수 (jti 로 blacklist).

자기 claim (role, scope, org_id) 자유 추가. 작게 유지 — 모든 JWT 가 모든 request 에 보냄.

핵심 보안 gotcha 셋

1. alg=none. 초기 JWT 라이브러리가 {"alg":"none"} token 받음 — signature 불필요. 공격자가 어느 token 이든 만들고 라이브러리가 받아들임. Verifier 에서 항상 alg: none 거부; 현대 라이브러리가 기본으로 함, 근데 네 것 검증.

2. Algorithm confusion. 공격자가 RS256 (비대칭) 으로 서명된 token 받고 verifier 한테 HS256 (대칭) 이라 말함. 코드가 algorithm 으로 verification key lookup 하면 verifier 가 공개 key 를 HMAC key 로 씀 — 공격자가 이미 가진 — 그리고 위조 token 받아들임. 항상 기대 algorithm 핀.

3. Payload 에 secret 넣기. Payload 는 base64-encoded, 암호화 아님. Token 가진 누구든 읽음. Password, 내부 user 디테일, 새는 token 이 노출 안 했으면 하는 거 넣지 마.

HS256 vs RS256 — 어느 algorithm

  • HS256 (HMAC-SHA256, 대칭): 공유 secret 하나. Issuer 와 verifier 가 같은 당사자 (혹은 완전 신뢰). 단순; 빠름; single-service backend 에 좋음.
  • RS256 (RSA, 비대칭): Private key 서명; public key 검증. Issuer (auth server) 가 private key 보유; verifier (여러 resource server) 가 public key 만. Issuer ≠ verifier 거나 verification 널리 배포할 때.

대부분 identity provider (Auth0, Okta, Google, Apple) 가 RS256 씀. Single-service 앱이 자주 단순함 위해 HS256.

JWT 가 verification 을 stateless 로 만듦; 그게 승리이자 거래. Request 당 DB lookup 없음 — signature 가 token 유효 증명; exp claim 이 여전히 신선 증명. 거래: stateful revocation infrastructure (Redis 의 jti blacklist) 추가 없이 exp 전에 JWT revoke 못 함. 짧은 exp + refresh token 이 실용적 타협.

Refresh 춤

짧은 access token (~15분) 이 누설 윈도우 빡빡 유지. 근데 user 한테 15분 마다 login 강제는 적대적. 타협: access token 을 더 긴 refresh token (~30일) 과 쌍:

  1. Login 이 둘 다 발급: access (15m) + refresh (30d).
  2. Client 가 모든 API 호출에 access 씀. 만료 가까우면 client 가 refresh token 을 /auth/refresh 에 POST, 새 access (그리고 optionally 새 refresh 로 rotate) 받음.
  3. Refresh token 이 access token 보다 더 조심스럽게 저장 — 보통 HttpOnly cookie, 절대 localStorage 안, 일반 API 호출에 절대 안 보냄.
  4. Logout 이 server 쪽 refresh token revoke; access token 이 15분 안에 자연 expire.

Refresh rotation 패턴: 성공한 /refresh 마다 새 refresh token 도 발급하고 옛 거 무효화. 이게 refresh token 새면 피해 한정 — 정당 user 의 다음 refresh 가 중복 사용 감지하고 전체 세션 무효화.

cwkPippa 의 JWT 현실

cwkPippa 가 현재 JWT 안 씀 — bearer token 이 expiry 가진 SQLite 에 저장된 opaque random string, DB lookup 으로 validate. Single-backend service 한텐 JWT 보다 단순하고 revocation trivial (그냥 row 삭제). API 를 여러 service 로 나누거나 verification 을 CDN edge 한테 넘기면 JWT 의 stateless verification 매력. 한 server 한텐 stateful token 이 단순함으로 이김. 맞는 도구는 topology 에 의존, 유행에 아님.

Code

손으로 JWT decode — header 와 payload 가 secret 아님·python
# 라이브러리 없이 손으로 JWT decode
import base64, json

token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1XzQyIiwiZXhwIjoxNzYwMDAwMDAwfQ.signature'
header_b64, payload_b64, sig_b64 = token.split('.')

# Base64url 이 padding 생략하니 패딩 추가
def b64url_decode(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))

header  = json.loads(b64url_decode(header_b64))
payload = json.loads(b64url_decode(payload_b64))
print(header)   # {'alg': 'HS256', 'typ': 'JWT'}
print(payload)  # {'sub': 'u_42', 'exp': 1760000000}
# Signature 가 유일 보호 부분 — 누구든 나머지 읽음.
PyJWT — 명시적 algorithm 핀 가진 발급 + 검증·python
# PyJWT 로 발급 + 검증 (production-grade)
import jwt, time

SECRET = 'env 에서 온 super-long-random-secret'

# Token 발급
def issue_token(user_id: str, role: str) -> str:
    payload = {
        'iss': 'cwkpippa',
        'sub': user_id,
        'aud': 'cwkpippa-api',
        'iat': int(time.time()),
        'exp': int(time.time()) + 900,  # 15분
        'role': role,
    }
    return jwt.encode(payload, SECRET, algorithm='HS256')

# Token 검증 (algorithm-pinned)
def verify_token(token: str) -> dict:
    return jwt.decode(
        token,
        SECRET,
        algorithms=['HS256'],          # 핀 — 다른 거 절대 받지 마
        audience='cwkpippa-api',
        issuer='cwkpippa',
        options={'require': ['exp', 'sub', 'iat']},
    )

# 사용
token = issue_token('u_42', 'daughter')
claims = verify_token(token)
print(claims)  # {'iss': 'cwkpippa', 'sub': 'u_42', 'role': 'daughter', ...}
Rotation + 재사용 감지 가진 refresh — production-grade 패턴·python
# Rotation 가진 refresh flow (canonical 패턴)
import jwt, secrets, time
from fastapi import FastAPI, HTTPException, Cookie, Response

app = FastAPI()
ACCESS_SECRET  = 'env 에서 온 access-secret'
REFRESH_STORE: dict[str, dict] = {}  # refresh_token -> {user_id, family_id, used: bool}

def issue_access(user_id: str) -> str:
    return jwt.encode({'sub': user_id, 'exp': int(time.time()) + 900}, ACCESS_SECRET, algorithm='HS256')

def issue_refresh(user_id: str, family_id: str | None = None) -> str:
    rt = secrets.token_urlsafe(32)
    REFRESH_STORE[rt] = {
        'user_id': user_id,
        'family_id': family_id or secrets.token_hex(8),
        'used': False,
    }
    return rt

@app.post('/auth/refresh')
async def refresh(response: Response, refresh_token: str | None = Cookie(None)):
    record = REFRESH_STORE.get(refresh_token or '')
    if not record or record['used']:
        # 재사용 감지 — 전체 family revoke
        if record and record['used']:
            for rt, r in list(REFRESH_STORE.items()):
                if r['family_id'] == record['family_id']:
                    del REFRESH_STORE[rt]
        raise HTTPException(401, detail='refresh token 무효 혹은 재사용')

    # Used 표시, 새 쌍 발급
    record['used'] = True
    new_refresh = issue_refresh(record['user_id'], record['family_id'])
    response.set_cookie('refresh_token', new_refresh, httponly=True, secure=True, samesite='strict')
    return {'access_token': issue_access(record['user_id']), 'expires_in': 900}

External links

Exercise

FastAPI 에서 JWT auth flow 처음부터 구현: /login 이 access (HS256, 15분) + refresh (opaque, 30일) token 돌려줌; /auth/refresh 가 refresh token rotate 하고 새 access 발급; /me 가 유효 access 필요. Refresh 에 재사용 감지 추가 (같은 refresh 두 번 제시면 family revoke). 그 다음 일부러 공격 셋 시도: (1) 재서명 없이 access token payload 수정 — 401 해야, (2) alg=none token 제출 — 401 해야, (3) 한 번 refresh 에 쓴 후 refresh token 제출 — 401 해야 AND family 의 다른 refresh token 들 무효화.
Hint
발급/검증에 PyJWT: jwt.encode({...}, SECRET, algorithm='HS256') 와 jwt.decode(token, SECRET, algorithms=['HS256']). 재사용 감지는 각 refresh token 과 함께 저장된 family_id 필요; rotation 에 used=True 표시; used=True 가진 request 에 같은 family_id 가진 모든 token 찾고 삭제. 세 공격이 다 401 로 실패 — 구현이 견고 증명하는 integration test.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.