JWT와 HMAC 검증: HS256 키·alg·claim을 안전하게 다루는 법

반응형

JWT는 그 자체로 안전한 인증 수단이 아니다. JWT는 claim을 전달하는 형식이고, JWS로 보호할 때 HMAC이나 digital signature를 사용할 수 있다. HS256 JWT는 발행자와 검증자가 같은 secret key를 공유하므로, 그 key를 가진 모든 검증자가 token도 새로 만들 수 있다.

안전한 구현은 library에서 decode()를 호출하는 것으로 끝나지 않는다. algorithm을 고정하고, key 선택을 통제하며, issuer·audience·만료와 token 용도를 함께 검증해야 한다.

JWT, JWS, JWE를 먼저 구분한다

용어 의미 payload가 보이는가?
JWT JSON claim을 URL-safe하게 전달하는 token 형식 보호 방식에 따라 다름
JWS payload에 MAC 또는 digital signature를 붙여 integrity·authenticity를 확인 compact payload는 보통 읽을 수 있음
JWE payload를 암호화해 confidentiality를 제공 복호화 key 없이는 내용 확인 불가

흔히 보는 세 부분의 JWT는 JWS compact serialization이다.

BASE64URL(protected header)
.
BASE64URL(payload)
.
BASE64URL(signature or MAC tag)

Base64URL은 encryption이 아니다. browser나 log에서 token을 얻은 사람은 header와 payload를 decode할 수 있다. password, 주민번호, API key 같은 secret을 payload에 넣지 않는다.

JWT 표준에는 JWS·JWE뿐 아니라 unsecured JWT도 정의돼 있지만, 일반적인 인증 token policy에서는 alg: none을 허용하지 않는다.

HS256은 어떻게 검증하는가

HS256은 HMAC-SHA-256으로 JWS signing input을 인증한다.

signing input = base64url(header) + "." + base64url(payload)
tag           = HMAC-SHA-256(shared key, signing input)

발행자와 검증자는 같은 key를 사용한다. 따라서 여러 microservice에 HS256 key를 복제하면 service 하나가 침해됐을 때 다른 service가 신뢰하는 token까지 만들 수 있다.

다음 구조에는 HS256이 비교적 잘 맞는다.

  • 발행과 검증이 같은 작은 trust boundary 안에 있다.
  • key를 가진 workload 수를 엄격히 통제할 수 있다.
  • verifier가 token을 만들 수 있어도 권한 모델이 깨지지 않는다.

반대로 여러 조직·client·service가 검증만 해야 한다면 asymmetric JWS가 더 자연스럽다. 발행자는 private key를 보관하고 verifier는 public key만 가진다. algorithm 이름보다 signing 권한과 verification 권한을 분리할 수 있는지가 선택 기준이다.

가장 위험한 실수: token의 alg를 그대로 믿기

protected header는 검증 전까지 공격자가 만든 값이다. library가 header의 alg만 보고 verification 방식을 고르게 두면 다음 문제가 생길 수 있다.

  • alg: none을 허용해 cryptographic verification을 생략
  • RS256용 public key를 HS256 shared secret처럼 사용하게 하는 algorithm confusion
  • 같은 key를 여러 algorithm과 purpose에 재사용

application configuration에서 issuer와 token 종류별 허용 algorithm을 정확히 고정해야 한다. HS256 token endpoint라면 allowlist는 HS256 하나로 시작하고, header가 다른 값을 요구하면 거부한다. key type과 algorithm도 일치시킨다.

algorithm migration 때 여러 algorithm을 동시에 열어 두는 기간은 별도 validation profile과 key set으로 관리한다. “호환성을 위해 library가 지원하는 모든 algorithm 허용”은 migration 전략이 아니다.

검증 순서는 policy로 고정한다

다음은 특정 library의 실행 가능한 API가 아니라, verification wrapper가 지켜야 할 policy를 나타낸 pseudocode다.

policy = {
  algorithms: ["HS256"],
  issuer: "https://issuer.example",
  audience: "orders-api",
  required_claims: ["iss", "aud", "exp"],
  max_clock_skew: "small, explicitly chosen",
  token_type: "access+jwt"
}

verify(token, policy, allowlisted_key_for(kid))

실제 wrapper는 다음 순서를 강제한다.

  1. token 전체 길이, segment 수와 JSON 구조에 제한을 둔다.
  2. header와 payload를 검증 전 data로만 parse한다.
  3. 미리 등록한 issuer·token 용도의 validation policy를 선택한다.
  4. alg가 policy의 exact allowlist와 일치하는지 확인한다.
  5. 허용된 kid로 key를 찾고 unknown·retired key를 거부한다.
  6. 검증된 JOSE library로 MAC 또는 signature를 확인한다.
  7. iss, aud, exp, nbf 등 contract가 요구한 claim과 type을 검사한다.
  8. token의 명시적 type·용도와 현재 endpoint의 목적이 맞는지 확인한다.
  9. 인증된 subject를 application authorization policy에 연결한다.

header나 payload를 미리 parse하는 것은 policy와 key 후보를 찾기 위한 제한된 작업일 뿐이다. cryptographic verification 전의 claim으로 access를 허용하거나 external request를 보내면 안 된다.

kid와 JWKS를 안전하게 다루기

kid는 key ID를 알려 주는 lookup hint다. 신뢰할 수 있는 key 자체가 아니다.

  • kid를 local file path, SQL·LDAP query에 직접 연결하지 않는다.
  • issuer별 allowlist나 cache에 등록된 key만 찾는다.
  • unknown kid는 모든 key를 무차별 대입하지 말고 fail closed한다.
  • key ID collision이 issuer 사이에서 섞이지 않게 namespace를 나눈다.

asymmetric JWT에서 jkux5u header가 임의 URL을 가리키게 두면 SSRF와 공격자 key 주입으로 이어질 수 있다. JWKS endpoint는 issuer configuration에 미리 고정하고 TLS, cache TTL, refresh failure와 rotation overlap을 관리한다. token이 알려 준 URL을 그대로 따라가지 않는다.

claim은 무엇을 확인해야 하나

signature나 HMAC이 맞아도 claim이 현재 service에 맞지 않으면 token을 거부해야 한다.

iss: 누가 발행했는가

정확한 canonical issuer 문자열과 일치해야 한다. 비슷한 domain, trailing slash 차이와 다른 environment issuer를 느슨하게 받아들이지 않는다.

aud: 누구를 위한 token인가

다른 API나 client용 token이 현재 API에 재사용되지 않게 한다. audience가 배열일 수 있는 library semantics도 확인한다.

exp, nbf: 언제 유효한가

현재 시각과 비교하되 clock skew는 작고 명시적인 값으로 둔다. 무제한 skew나 과도하게 긴 token lifetime은 만료의 의미를 약하게 만든다. base JWT가 모든 claim을 의무화하지는 않으므로, application profile에서 필요한 claim을 required로 지정해야 한다.

sub, jti, custom claim

sub는 issuer 안에서 어떤 주체를 나타내는지 contract를 정한다. jti를 넣는 것만으로 replay가 방지되지는 않는다. replay 차단에 쓰려면 저장소와 수명, 중복 처리 policy가 필요하다. role·scope 같은 custom claim은 schema와 type을 검증하고 server-side authorization에서 해석한다.

HS256 key의 생성·보관·rotation

RFC 7518은 HS256 key가 최소 256-bit여야 한다고 요구한다. 사람이 만든 password나 짧은 environment 이름을 hash해 key로 쓰지 말고 CSPRNG로 생성한다.

key를 service마다 복사하기 전에 HMAC 키 배포 설계에서 다룬 것처럼 중앙 HMAC service, secret manager와 asymmetric signature 중 어느 trust boundary가 맞는지 선택한다.

rotation에는 적어도 두 상태가 필요하다.

signer:   current key로만 새 token 발행
verifier: current key + bounded overlap의 previous key 검증

각 key에는 고유한 kid를 주고, previous key는 발행을 중단한 뒤 최대 token lifetime과 clock skew가 지난 시점에 retire한다. key가 유출됐다면 정상 overlap을 기다리지 않고 revoke와 session 대응을 수행한다.

JWT는 발행 후 독립적으로 검증할 수 있다는 장점 때문에 즉시 revoke가 어렵다. 짧은 access-token lifetime, refresh-token 통제, denylist·session version처럼 위험에 맞는 stateful mechanism을 함께 검토한다.

검증됐다는 사실이 권한을 보장하지 않는다

JWT verification은 “허용된 issuer가 이 claim을 담아 발행했다”는 조건을 확인한다. 사용자가 현재 주문을 읽을 권한이 있는지, 계정이 비활성화됐는지, 조직 관계가 바뀌었는지는 별도의 authorization 문제다.

다음 테스트를 positive·negative case로 함께 둔다.

  • 정상 token
  • 변조된 payload와 tag
  • none, 예상 밖 algorithm과 잘못된 key type
  • unknown·retired kid
  • 다른 issuer·audience·token type
  • 만료·아직 유효하지 않은 token과 경계 시각
  • claim 누락, 잘못된 JSON type과 과도하게 큰 token
  • 다른 endpoint용 token의 교차 사용

안전한 JWT는 세 부분으로 생긴 문자열이 아니라 고정된 algorithm, 통제된 key, 엄격한 claim·purpose policy를 한 번에 적용한 validation contract다.

cookie·session ID·OAuth token과 JWT의 역할이 섞인다면 응용 계층 식별자와 token의 차이를 먼저 보면 전체 경계를 잡기 쉽다.

참고 자료

반응형
KEEP READING
카테고리 전체 보기 →

댓글