JWT HS256 직접 만들기: alg none, 약한 비밀 키, exp/aud

표준 라이브러리만으로 HS256 JWT 를 만들고 검증한 뒤, alg: none 으로 admin 위조, 약한 비밀 키 대입(0.15ms), exp/aud 검사, 토큰 저장 위치까지 실행 결과로 확인한다.

시리즈: 보안 기초 강좌 4/6 (중급). API, 모바일 앱 백엔드, SPA 에서 JSON Web Token 을 발급하거나 검사하지만, 라이브러리 내부는 들여다본 적 없는 개발자를 위한 글입니다. 다 읽으면 표준 라이브러리만으로 HS256 JWT 를 만들고 검증할 수 있고, JWT 를 "열린 문"으로 만드는 세 가지 실수를 설명할 수 있습니다.

JWT 사고는 시시한 원인에서 납니다. 비밀 키가 약하거나, 거부해야 할 토큰을 받아들이거나, 누구나 고칠 수 있는 role 클레임을 믿거나. 정보보안기사 실기에서 "인증 우회"로 묶이는 문제들이 실무에서는 대부분 이 셋 중 하나입니다. 그리고 다른 사람 계정으로 들어가 개인정보를 볼 수 있게 되면, 개인정보보호법이 요구하는 안전성 확보 조치를 못 한 셈이 될 수 있습니다(일반론이며 구체적 판단은 전문가에게). 이 글은 설명 대신 작은 검증기를 직접 짜고, 일부러 깨고, 깨지는 속도를 재고, 고칩니다. 실행 환경은 Python 3.9.6, PHP 8.5.6, OpenSSL 4.1.0, Apple Silicon Mac(M1 Pro)이고 JWT 라이브러리는 설치하지 않았습니다. 그게 이 글의 요점입니다. 모든 공격은 내 컴퓨터에서 만든 토큰과 키만 대상으로 했습니다.

1강의 MAC 개념과 3강의 랜덤 키 이야기를 전제로 합니다.

JWT 는 base64url 문자열 셋과 HMAC 하나

HS256 JWT 는 점으로 구분된 세 부분입니다.

  1. 헤더: {"alg":"HS256","typ":"JWT"} 같은 JSON.
  2. 페이로드: sub(주체, 즉 사용자), exp(만료, Unix 초), aud(대상, 이 토큰을 받을 서비스) 같은 "클레임"을 담은 JSON.
  3. 서명: base64url(헤더) + "." + base64url(페이로드) 라는 ASCII 바이트열에, 공유 비밀 키로 계산한 HMAC-SHA256.

base64url 은 base64 의 +, / 를 -, _ 로 바꾸고 끝의 = 패딩을 뗀 것입니다. 전체가 15줄이면 됩니다.

# jwt_min.py - 표준 라이브러리만으로 HS256 JWT 만들기
import base64, hashlib, hmac, json

def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")

def b64url_decode(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def sign_hs256(payload: dict, key: bytes) -> str:
    header = {"alg": "HS256", "typ": "JWT"}
    h = b64url(json.dumps(header, separators=(",", ":")).encode())
    p = b64url(json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode())
    sig = hmac.new(key, f"{h}.{p}".encode("ascii"), hashlib.sha256).digest()
    return f"{h}.{p}.{b64url(sig)}"

if __name__ == "__main__":
    KEY = b"demo-key-for-this-post-only-0001"     # 32바이트. 진짜 비밀 키 아님
    tok = sign_hs256({"sub": "user-42", "name": "김민준", "role": "user",
                      "iat": 1794614400, "exp": 1794618000}, KEY)
    print(tok)
    for part in tok.split(".")[:2]:
        print(b64url_decode(part).decode())
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQyIiwibmFtZSI6Iuq5gOuvvOykgCIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzk0NjE0NDAwLCJleHAiOjE3OTQ2MTgwMDB9.TVMO70qETWreyy144b6KJ2GFFg8ZC4gk-G3spoZaOnI
{"alg":"HS256","typ":"JWT"}
{"sub":"user-42","name":"김민준","role":"user","iat":1794614400,"exp":1794618000}

방금 일어난 일을 보세요. 두 번째 반복문은 키 없이 페이로드를 출력했습니다. "김민준"도 UTF-8 그대로 읽힙니다. 서명된 JWT 는 암호화가 아닙니다. 토큰을 볼 수 있는 사람(브라우저 개발자 도구, 프록시 로그, 크래시 리포트)은 모든 클레임을 읽습니다. 이름, 전화번호, 주민등록번호 같은 고유식별정보는 절대 넣지 마세요.

흐름도. alg 가 HS256 인 헤더 JSON 과 클레임을 담은 페이로드 JSON 을 각각 패딩 없는 base64url 로 인코딩한다. 둘을 점으로 이은 것이 서명 대상이다. 비밀 키로 그 서명 대상에 HMAC-SHA256 을 계산하고, 32바이트 결과를 base64url 로 바꾼 것이 세 번째 부분이다. 토큰은 헤더.페이로드.서명 이 된다.
HS256 JWT 는 base64url 로 인코딩한 JSON 두 개와, 바로 그 바이트열에 대한 HMAC-SHA256 태그로 이루어진다. 페이로드는 누구나 읽을 수 있고, 맞는 서명은 키를 가진 쪽만 만들 수 있다.

형식이 표준대로인지 확인하려고, 같은 방식으로 만든 토큰을 PHP 와 OpenSSL CLI 로도 검증했습니다. 둘 다 같은 서명을 냈습니다(아래는 name 이 Alex 인 영어판 토큰으로 실행한 예).

<?php
function b64url_decode(string $s): string { return base64_decode(strtr($s, '-_', '+/') . str_repeat('=', (4 - strlen($s) % 4) % 4)); }
[$h, $p, $s] = explode('.', $argv[1]);
$key = 'demo-key-for-this-post-only-0001';
$hdr = json_decode(b64url_decode($h), true, 8, JSON_THROW_ON_ERROR);
if (($hdr['alg'] ?? null) !== 'HS256') exit("alg not allowed\n");
$good = hash_hmac('sha256', "$h.$p", $key, true);
echo hash_equals($good, b64url_decode($s)) ? "PHP: signature OK\n" : "PHP: bad signature\n";
$ php verify.php "$TOKEN"
PHP: signature OK
$ printf %s "$(echo $TOKEN | cut -d. -f1-2)" | openssl dgst -sha256 -hmac demo-key-for-this-post-only-0001 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '='
tYECXfS5xKO3rsp1e3ArWetCHPiygT8spAmzNzjhBfc

여기에 중요한 규칙이 하나 숨어 있습니다. 받은 바이트열 그대로 검증한다. 페이로드를 파싱했다가 다시 JSON 으로 만들어서 검증하면, 키 순서나 공백(", " 와 ",") 차이로 바이트열이 바뀌어 서명이 전부 틀어집니다. 한글이면 ensure_ascii 설정에 따라 "김민준" 과 "김민준" 으로 갈리는 것도 주의하세요.

흔한 실수: alg 를 토큰이 고르게 둔다 → admin 위조 → 해결

초기 JWT 라이브러리 상당수는 헤더에 적힌 알고리즘으로 검증했습니다. 게다가 RFC 7518 은 "보호 없는" JWT 를 위한 "alg": "none" 까지 정의합니다. 둘을 합치면 이런 검증기가 됩니다. 일부러 나쁘게 짰습니다.

# jwt_naive.py - 일부러 나쁜 검증기 (내 컴퓨터 안에서만 하는 데모). jwt_min.py 옆에 저장
import hashlib, hmac, json, time
from jwt_min import b64url, b64url_decode, sign_hs256

KEY = b"demo-key-for-this-post-only-0001"

def verify_naive(token: str, key: bytes) -> dict:
    h, p, s = token.split(".")
    header = json.loads(b64url_decode(h))
    if header["alg"] == "HS256":
        good = b64url(hmac.new(key, f"{h}.{p}".encode(), hashlib.sha256).digest())
        if s != good:
            raise ValueError("bad signature")
    elif header["alg"].lower() == "none":
        pass                                  # "서명 없는 토큰이니 검사할 게 없다"
    else:
        raise ValueError("unsupported alg")
    return json.loads(b64url_decode(p))

T = 1794614400
good = sign_hs256({"sub": "user-42", "name": "김민준", "role": "user",
                   "aud": "api.example.com", "iat": T, "exp": T + 900}, KEY)
print("naive, real token :", verify_naive(good, KEY)["role"])

h, p, s = good.split(".")
claims = json.loads(b64url_decode(p)); claims["role"] = "admin"
p2 = b64url(json.dumps(claims, separators=(",", ":"), ensure_ascii=False).encode())
try:
    verify_naive(f"{h}.{p2}.{s}", KEY)
except ValueError as e:
    print("naive, edited role:", e)

forged = b64url(b'{"alg":"none","typ":"JWT"}') + "." + p2 + "."   # 서명은 비움
print("naive, alg=none   :", verify_naive(forged, KEY)["role"])
naive, real token : user
naive, edited role: bad signature
naive, alg=none   : admin

페이로드만 바꾼 토큰은 HMAC 이 맞지 않아 걸러졌습니다. 그런데 헤더를 none 으로 바꾸고 서명을 빼자 검증기는 role: admin 을 돌려줍니다. 키는 전혀 쓰지 않았습니다. .lower() 때문에 None, NONE, nOnE 도 통과합니다.

해결: 알고리즘은 다른 무엇보다 먼저, 서버가 자기 설정으로 정합니다. 서명은 상수 시간으로 비교하고, 그다음 클레임을 검사합니다. 아래 그림의 순서입니다.

def verify(token: str, key: bytes, *, audience: str, leeway: int = 30, now=None) -> dict:
    try:
        h, p, s = token.split(".")
    except ValueError:
        raise ValueError("malformed token")
    header = json.loads(b64url_decode(h))
    if header.get("alg") != "HS256":           # 허용 목록. 정하는 쪽은 서버
        raise ValueError(f"alg not allowed: {header.get('alg')!r}")
    good = hmac.new(key, f"{h}.{p}".encode(), hashlib.sha256).digest()
    if not hmac.compare_digest(b64url_decode(s), good):
        raise ValueError("bad signature")
    claims = json.loads(b64url_decode(p))
    now = time.time() if now is None else now
    if "exp" not in claims or now > claims["exp"] + leeway:
        raise ValueError("expired")
    if "nbf" in claims and now < claims["nbf"] - leeway:
        raise ValueError("not yet valid")
    aud = claims.get("aud")
    if audience not in (aud if isinstance(aud, list) else [aud]):
        raise ValueError(f"wrong audience: {aud!r}")
    return claims

# jwt_naive.py 끝에 추가. 위와 같은 토큰으로 확인
for t, label in ((forged, "alg=none"), (f"{h}.{p2}.{s}", "edited role"), (good, "real token")):
    try: print("fixed,", label.ljust(11), ":", verify(t, KEY, audience="api.example.com", now=T + 60)["role"])
    except ValueError as e: print("fixed,", label.ljust(11), ":", e)
for when, aud in ((T + 2000, "api.example.com"), (T + 60, "admin.example.com")):
    try: verify(good, KEY, audience=aud, now=when)
    except ValueError as e: print("fixed, now=T+%-5d aud=%s: %s" % (when - T, aud, e))
fixed, alg=none    : alg not allowed: 'none'
fixed, edited role : bad signature
fixed, real token  : user
fixed, now=T+2000  aud=api.example.com: expired
fixed, now=T+60    aud=admin.example.com: wrong audience: 'api.example.com'
판정 흐름. 토큰을 세 부분으로 나누고 세 개가 아니면 거부. alg 가 서버 설정대로 정확히 HS256 인가. 아니면 거부. 다시 계산한 HMAC 과 서명을 compare_digest 로 비교해 일치하는가. 아니면 거부. 현재 시각이 exp 와 약간의 여유 안쪽인가. 아니면 만료로 거부. 기대하는 audience 가 aud 에 있는가. 없으면 거부. 여기까지 통과해야 비로소 클레임을 믿는다.
서버가 주도하는 검증 순서. 알고리즘은 토큰이 아니라 서버 설정으로 정하고, 모든 검사는 실패하면 거부한다. 이 글의 순진한 검증기는 첫 번째 상자를 건너뛰어 alg: none 을 통과시켰다.

왜 != 대신 compare_digest 일까요? 단순 비교는 처음 다른 바이트에서 멈출 수 있고, 그 시간 차이로 정보가 샐 수 있기 때문입니다. 그게 Python 에서 정말 측정되는지는 6강에서 실험합니다. 어느 쪽이든 상수 시간 함수를 쓰세요. 비용은 없습니다.

규칙: 검증 방법을 토큰이 고르게 두지 않는다. 실제 라이브러리에서는 매번 algorithms=["HS256"](또는 그에 해당하는 인수)를 명시한다.

약한 비밀 키는 0.15ms 만에 찾는다 (실측)

HS256 의 강도는 곧 키의 강도입니다. 키가 단어라면, 유효한 토큰을 하나만 가진 사람도 속도 제한도 우리 쪽 로그도 없는 오프라인에서 추측을 시험할 수 있습니다. 후보마다 HMAC 을 계산해 서명과 비교하면 끝입니다. 내 컴퓨터에서 만든 토큰을 대상으로, 직접 만든 20단어 목록을 5가지로 변형(그대로, 첫 글자 대문자, +123, +2026, 첫 글자 대문자 + !)한 100개 후보로 시험했습니다. 국내 개발 현장에서 흔한 "로마자 단어 + 123" 패턴을 가정해 키는 saranghae123 으로 했습니다.

# jwt_crack.py - 내 컴퓨터 안에서만 하는 데모: 약한 HS256 키 vs 직접 만든 작은 목록
import hashlib, hmac, secrets, time
from jwt_min import sign_hs256, b64url_decode

WORDS = ["secret", "password", "changeme", "jwtsecret", "mysecret", "admin", "letmein",
         "qwerty", "dragon", "sunshine", "himitsu", "sakura", "saranghae", "hangang",
         "secreto", "contrasena", "futbol", "tequila", "dev", "test"]   # 직접 만든 20단어
def candidates():
    for w in WORDS:
        for v in (w, w.capitalize(), w + "123", w + "2026", w.capitalize() + "!"):
            yield v.encode()

token = sign_hs256({"sub": "user-42", "role": "user", "exp": 1794618000}, b"saranghae123")
h, p, s = token.split(".")
msg, target = f"{h}.{p}".encode(), b64url_decode(s)

t0 = time.perf_counter_ns(); tried = 0
for key in candidates():
    tried += 1
    if hmac.compare_digest(hmac.new(key, msg, hashlib.sha256).digest(), target):
        print(f"found {key.decode()!r} after {tried} of 100 candidates "
              f"in {(time.perf_counter_ns() - t0) / 1e6:.3f} ms")
        break

rates = []                            # HMAC 자체 속도: 랜덤 키 20만 개 × 5회
keys = [secrets.token_bytes(12) for _ in range(200_000)]
for _ in range(5):
    t0 = time.perf_counter_ns()
    for k in keys: hmac.new(k, msg, hashlib.sha256).digest()
    rates.append(len(keys) / ((time.perf_counter_ns() - t0) / 1e9))
rates.sort()
print(f"HMAC-SHA256 guesses/s (median of 5): {rates[2]:,.0f}  runs={[round(x) for x in rates]}")
found 'saranghae123' after 63 of 100 candidates in 0.149 ms
HMAC-SHA256 guesses/s (median of 5): 548,291  runs=[546601, 546701, 548291, 549077, 549934]

싱글 스레드 순수 Python 으로 초당 약 54.8만 회를 시도했습니다. GPU 를 쓰는 전용 분석 도구는 훨씬 빠르지만 여기서는 실행하지 않았으므로, 표의 그 열은 초당 10¹⁰회라는 가정값입니다.

비밀 키 후보 수 실측 548,291회/초 평균 시간 가정 10¹⁰회/초
사전 단어(+ 변형) 약 100~수백만 밀리초~초 즉시
랜덤 소문자 8자 26⁸ 2.2일 10초
랜덤 영숫자 12자 62¹² 약 9,300만 년 약 5,100년
secrets.token_bytes(32) 2²⁵⁶ 약 3.4×10⁶³년 약 1.8×10⁵⁹년

해결: 키는 CSPRNG 로 한 번만 만들고 코드 밖에 둡니다.

$ python3 -c "import secrets; print(secrets.token_urlsafe(32))"
$ openssl rand -base64 32

환경 변수나 시크릿 관리 서비스에 저장하고 Git 에는 넣지 않습니다. 키를 교체할 수 있도록 헤더에 kid(키 ID)를 둡니다. 검증할 때는 kid 가 가리키는 키를 쓰되, 반드시 내가 정해 둔 고정된 키 목록 안에서만 찾습니다.

규칙: HS256 비밀 키는 비밀번호가 아니라 키다. 랜덤 32바이트, 단어는 금지.

exp, aud, nbf 와 시계 오차

서명이 증명하는 것은 "키를 가진 쪽이 만들었다"는 것뿐입니다. "지금, 여기서" 받아들여도 되는지는 클레임이 정합니다.

  • exp(만료): 지나면 거부. exp 가 없는 토큰도 거부했습니다. 만료 없는 토큰은 영구 비밀번호와 같습니다. 액세스 토큰은 짧게(5~15분).
  • nbf(이전에는 무효)와 iat(발급 시각): 미래에서 온 토큰을 잡아냅니다.
  • aud(대상): api.example.com 용 토큰으로 admin.example.com 에 들어가면 안 됩니다. 키를 공유하더라도요. 위 출력에서 거부된 것을 볼 수 있습니다.
  • iss(발급자): 토큰을 발급하는 서비스가 여럿이면 검사합니다.
  • 여유(leeway): 서버끼리 시계는 몇 초씩 어긋납니다. 여기서는 30초. 크게 잡으면 모든 토큰의 수명이 몰래 늘어납니다.

토큰을 어디에 저장할까 (설명만, 실행 안 함)

실제 사고는 저장 위치에서 많이 납니다. 이 글에서 브라우저는 실행하지 않았습니다.

저장 위치 XSS 로 읽히나? 자동 전송(CSRF 위험)? 메모
localStorage 읽힘 안 됨 XSS 하나면 모든 토큰 유출
HttpOnly; Secure; SameSite=Lax 쿠키 안 읽힘 됨(SameSite 로 제한) CSRF 방어와 함께. 쿠키와 세션 참고
메모리만(JS 변수) 페이지 실행 중에만 안 됨 새로고침하면 사라짐. 리프레시 토큰은 HttpOnly 쿠키에
모바일: iOS Keychain / Android Keystore 해당 없음 안 됨 SharedPreferences 나 평문 파일은 피함

그리고 먼저 물어야 할 질문은 "정말 JWT 가 필요한가"입니다. JWT 는 exp 전에 취소하려면 서버 쪽 목록이 필요하고, 그건 결국 세션 저장소입니다. 웹 앱 하나가 자기 백엔드와만 대화한다면 HttpOnly 쿠키에 넣은 랜덤 세션 ID(3강)가 더 단순하고 취소도 됩니다. JWT 는 서로 다른 서비스가 중앙 저장소에 묻지 않고 토큰을 확인해야 할 때 제값을 합니다. 세션 쪽 이야기는 PHP 세션 보안을 보세요.

그 밖에 알아 둘 함정(설명만): 알고리즘 혼동(RS256 과 HS256 을 모두 받는 서버가 RSA 공개 키를 HMAC 비밀 키로 써서 HS256 토큰을 검증해 버림. 키마다 알고리즘을 하나로 고정), kid 인젝션(kid 를 파일 경로나 SQL 값으로 씀. PDO 와 SQL 인젝션 참고), 공격자 서버의 키를 가리키는 jku/x5u 헤더, 라이브러리의 decode 와 verify 혼동(decode 는 서명을 안 봄).

솔직한 한계와 버전 메모

  • JWT 는 RFC 7519(2015), 서명 형식은 JWS(RFC 7515), "none" 은 RFC 7518 에 정의되어 있습니다. RFC 8725(2020)가 알고리즘 허용 목록을 포함한 모범 사례를 정리합니다. JWT 라이브러리(PyJWT, firebase/php-jwt, jose 등)는 실행하지 않았으므로 특정 버전의 기본 동작에 대해서는 아무 주장도 하지 않습니다. 알고리즘을 고정하는 인수는 쓰는 라이브러리 문서에서 확인하세요.
  • 여기 코드는 HS256 과 compact 형식만 지원하고 iss, typ, crit 는 검사하지 않습니다. 교재일 뿐 그대로 배포할 코드가 아닙니다.
  • 초당 548,291회는 싱글 스레드 CPython 3.9.6 값입니다. 10¹⁰회/초 열은 가정입니다.

연습 문제

  1. 동료의 검증기는 먼저 페이로드를 디코딩해 exp 를 확인하고, 그다음 서명을 확인한다. 정상 토큰에서는 결과가 같다. 문제가 있을까?
  2. sign_hs256 의 헤더에 "kid": "2026-11" 을 넣는 한 줄 변경을 써 보라. kid 가 악용되지 않게 하려면 검증기의 어디를 어떻게 바꿔야 하나?

정답

  1. 동작은 하지만, 진짜인지 모르는 상태에서 공격자가 마음대로 만든 JSON 을 파싱하고 로직을 돌립니다. 그 부분의 버그(거대한 숫자로 인한 오류, 클레임을 로그에 남기는 것 등)는 키 없이 건드릴 수 있습니다. 서명을 먼저 검증하고, 그다음 클레임을 믿고 검사합니다.
  2. header = {"alg": "HS256", "typ": "JWT", "kid": "2026-11"}. 검증기는 KEYS = {"2026-11": ..., "2026-05": ...} 같은 고정 딕셔너리에서 키를 찾고, 모르는 kid 는 거부합니다. kid 를 경로, URL, SQL 값으로 쓰면 안 됩니다.

다음

자주 묻는 질문

JWT 페이로드는 암호화되어 있나요?

아닙니다. 서명된 JWT(JWS)는 base64url 로 인코딩한 JSON 과 서명일 뿐이라 토큰을 가진 사람은 누구나 내용을 읽을 수 있습니다. 글에서는 키 없이 base64.urlsafe_b64decode 만으로 '김민준'이 든 페이로드가 출력됐습니다. 서명이 증명하는 것은 누가 만들었는지와 바뀌지 않았는지뿐입니다.

JWT alg: none 취약점이란 무엇인가요?

토큰 자신의 헤더에서 알고리즘을 읽는 검증기에 alg: none(서명 없음)을 지정하는 공격입니다. 글의 순진한 검증기는 서명을 비우고 role 을 admin 으로 바꾼 위조 토큰을 받아들였습니다. 해결책은 서버에서 알고리즘을 HS256 하나로 고정하고, 다른 값은 서명을 보기 전에 거부하는 것입니다.

HS256 비밀 키는 얼마나 길어야 하나요?

CSPRNG 에서 만든 32바이트(예: secrets.token_bytes(32))를 쓰세요. 단어 기반 키는 100개 후보 목록에서 0.15ms 만에 찾았습니다. 측정한 초당 약 55만 회 속도로는 랜덤 32바이트를 찾는 데 평균 약 3.4×10^63년이 걸립니다.

  • #보안
  • #중급
  • #JWT
  • #Python
  • #PHP
  • #HMAC

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑