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 라이브러리는 설치하지 않았습니다. 그게 이 글의 요점입니다. 모든 공격은 내 컴퓨터에서 만든 토큰과 키만 대상으로 했습니다.
JWT 는 base64url 문자열 셋과 HMAC 하나
HS256 JWT 는 점으로 구분된 세 부분입니다.
- 헤더:
{"alg":"HS256","typ":"JWT"}같은 JSON. - 페이로드:
sub(주체, 즉 사용자),exp(만료, Unix 초),aud(대상, 이 토큰을 받을 서비스) 같은 "클레임"을 담은 JSON. - 서명:
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 는 암호화가 아닙니다. 토큰을 볼 수 있는 사람(브라우저 개발자 도구, 프록시 로그, 크래시 리포트)은 모든 클레임을 읽습니다. 이름, 전화번호, 주민등록번호 같은 고유식별정보는 절대 넣지 마세요.
형식이 표준대로인지 확인하려고, 같은 방식으로 만든 토큰을 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'
왜 != 대신 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¹⁰회/초 열은 가정입니다.
연습 문제
- 동료의 검증기는 먼저 페이로드를 디코딩해
exp를 확인하고, 그다음 서명을 확인한다. 정상 토큰에서는 결과가 같다. 문제가 있을까? sign_hs256의 헤더에"kid": "2026-11"을 넣는 한 줄 변경을 써 보라.kid가 악용되지 않게 하려면 검증기의 어디를 어떻게 바꿔야 하나?
정답
- 동작은 하지만, 진짜인지 모르는 상태에서 공격자가 마음대로 만든 JSON 을 파싱하고 로직을 돌립니다. 그 부분의 버그(거대한 숫자로 인한 오류, 클레임을 로그에 남기는 것 등)는 키 없이 건드릴 수 있습니다. 서명을 먼저 검증하고, 그다음 클레임을 믿고 검사합니다.
header = {"alg": "HS256", "typ": "JWT", "kid": "2026-11"}. 검증기는KEYS = {"2026-11": ..., "2026-05": ...}같은 고정 딕셔너리에서 키를 찾고, 모르는kid는 거부합니다.kid를 경로, URL, SQL 값으로 쓰면 안 됩니다.
다음
- 이전: 보안 기초 강좌 3/6: secrets 로 안전한 토큰 만들기
- 다음: 보안 기초 강좌 5/6: AES-GCM 대칭 암호 제대로 쓰기 (페이로드를 정말 비밀로 해야 할 때)
- 프로그래밍 학습 로드맵으로 돌아가기. JWT 는 반드시 HTTPS 로: HTTPS, TLS, 인증서
자주 묻는 질문
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년이 걸립니다.