JWT 검증에서 alg를 믿으면 안 되는 이유: none, RS256→HS256 혼동과 알고리즘 고정

JWT 헤더의 alg는 토큰을 만든 쪽이 적는 값입니다. alg none과 RS256→HS256 혼동이 통하는 이유, RFC 8725가 요구하는 알고리즘 고정과 키 하나당 알고리즘 하나 원칙, 서명 다음에 확인할 iss·aud·exp·typ를 정리했습니다.

개발보안웹

JWT의 첫 부분인 헤더에는 alg가 들어 있고, 이 값은 토큰을 어떤 알고리즘으로 서명했는지 알려 줍니다. 문제는 이 값을 적는 사람이 토큰을 보낸 쪽이라는 점입니다. 검증하는 쪽이 alg를 보고 검증 방법을 고르면, 공격자가 검증 방법을 고르게 됩니다.

이 글은 JWT 모범 사례 문서인 RFC 8725(BCP 225, 2020년 2월, RFC 7519를 갱신)와 그 바탕인 RFC 7515(JWS), RFC 7518(JWA), RFC 7519(JWT)를 기준으로 합니다. JWS로 표현된 JWT를 다루며, 여기에는 서명 없는 JWT(Unsecured JWT, alg: none)도 포함됩니다. 암호화된 JWT(JWE)는 범위 밖입니다. 2026년 10월 10일에 확인한 RFC Editor의 RFC 8725 정보 페이지에는 이 문서를 대체(Obsoleted by)하거나 갱신(Updated by)한 RFC가 표시되어 있지 않습니다.

alg는 토큰 안에 있다

RFC 7515 4.1.1절에서 alg는 JWS를 보호하는 데 쓴 암호 알고리즘을 나타내는 헤더 파라미터이고, 반드시 있어야 합니다(MUST). 등록된 값은 RFC 7518 3.1절의 표에 있습니다. HMAC인 HS256·HS384·HS512, RSA 서명인 RS256·PS256 계열, ECDSA인 ES256 계열, 그리고 서명이 없다는 뜻의 none입니다.

헤더는 Base64URL로 인코딩된 JSON일 뿐이라 누구나 읽고 바꿔 쓸 수 있습니다(Base64 글 참고). JWS 컴팩트 직렬화(점 두 개로 나뉜 흔한 형태)에서는 헤더가 서명 입력에 포함되므로, 헤더를 바꾸면 원래 서명은 맞지 않게 됩니다. RFC 7515 10.7절도 alg를 보호 헤더에 두는 것을 알고리즘 대체 공격의 완화책으로 듭니다.

그런데 이 보호는 검증자가 자기가 정한 방법으로 검증할 때만 의미가 있습니다. alg를 바꾼다고 해서 공격자가 아무 알고리즘으로나 유효한 서명을 만들 수 있는 것은 아닙니다. 문제는 검증자가 공격자가 고른 방법을 받아들이고, 그 방법이 공격자도 통과시킬 수 있는 것일 때입니다. none이나, 알려진 RSA 공개 키를 HMAC 비밀로 쓰는 HS256이 그런 경우입니다. 그러면 공격자는 헤더를 바꾸고, 결함 있는 검증자가 받아들이는 값을 새로 만들 수 있습니다.

알려진 두 가지 실패

RFC 8725 2.1절은 alg 헤더가 라이브러리와 애플리케이션의 설계 결함과 맞물려 실제 공격으로 이어진 경우를 두 가지 적습니다.

첫째는 alg를 none으로 바꾸는 것입니다. 일부 라이브러리는 이 값을 믿고 서명을 확인하지 않은 채 JWT를 “검증”했습니다. RFC 7519 6.1절에 실린 서명 없는 JWT 예시는 이렇게 생겼습니다(표시를 위해 줄을 나눴습니다).

eyJhbGciOiJub25lIn0
.
eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFt
cGxlLmNvbS9pc19yb290Ijp0cnVlfQ
.

첫 부분은 {"alg":"none"}, 가운데는 {"iss":"joe","exp":1300819380,"http://example.com/is_root":true}이고(원문에는 줄바꿈이 있습니다), 마지막 점 뒤의 서명은 빈 문자열입니다. 키가 전혀 필요 없으니 누구나 만들 수 있는 형태입니다.

둘째는 RS256(RSA 서명)을 HS256(HMAC)으로 바꾸는 것입니다. 일부 라이브러리는 이때 RSA 공개 키를 HMAC 공유 비밀로 써서 HMAC-SHA256 검증을 시도했습니다(RFC 8725가 인용하는 CVE-2015-9235). 공개 키는 이름대로 공개된 값이므로, 공격자도 같은 바이트로 HMAC을 계산해 통과하는 서명을 만들 수 있습니다. 검증 코드가 “이 키로 검증해 줘”라고만 하고 알고리즘은 헤더에 맡길 때 생기는 구조입니다.

명세가 요구하는 것

RFC 7515 5.2절은 JWS 검증 절차의 끝에 이렇게 적습니다. 어떤 알고리즘을 쓸 수 있는지는 애플리케이션이 정하는 것이고, 서명이 검증되더라도 그 알고리즘이 애플리케이션이 받아들일 수 있는 것이 아니면 JWS를 무효로 봐야 한다(SHOULD). RFC 7519 7.2절도 JWT에 대해 같은 말을 합니다.

RFC 8725는 이것을 더 강하게 씁니다.

  • 라이브러리는 호출하는 쪽이 지원 알고리즘 집합을 지정할 수 있게 해야 하고, 그 밖의 알고리즘은 쓰면 안 됩니다(3.1절, MUST).
  • 라이브러리는 alg가 실제로 수행하는 암호 연산의 알고리즘과 같은지 확인해야 합니다(3.1절, MUST).
  • 키 하나는 정확히 하나의 알고리즘에만 써야 하고, 암호 연산을 할 때 이를 확인해야 합니다(3.1절, MUST).
  • 애플리케이션은 자기 보안 요구를 만족하는, 현재 기준으로 안전한 알고리즘만 허용해야 합니다(3.2절, MUST).

none에 대한 규칙은 RFC 7518 3.6절에 있습니다. 서명 없는 JWS를 기본으로 받아들이면 안 되고(MUST NOT), 다운그레이드 공격을 막기 위해 애플리케이션 전체에 “none 허용”을 켜면 안 됩니다(MUST NOT). 허용이 필요하면 객체마다 표시해야 합니다(SHOULD). 8.5절은 그 이유를 예로 듭니다. HTTP로 받는 객체에는 서명을 요구하고 클라이언트 인증이 있는 HTTPS로 받는 객체에는 none을 허용하는 애플리케이션이 none을 전역으로 허용하면, 공격자가 HTTP로 서명 없는 JWS를 보내도 통과합니다.

하나 더 알아 둘 점이 있습니다. RFC 7519 8절은 표준을 따르는 JWT 구현이 HS256과 none을 반드시 구현해야 한다고 정합니다(MUST). 라이브러리가 none을 아예 모를 거라고 기대할 수 없다는 뜻입니다. 막는 곳은 검증 호출입니다. RFC 8725 3.2절도 라이브러리가 호출자가 명시적으로 요청하지 않는 한 none JWT를 받아들이지 말아야 한다고(SHOULD NOT) 적습니다.

참고로 RFC 8725 3.2절은 JWT가 TLS 같은 전송 계층으로 끝에서 끝까지 보호될 때는 none이 괜찮을 수 있다고도 합니다. 다만 그런 경우에도 다른 수단으로 보호된다는 전제가 있을 때만 쓰라는 조건이 붙습니다.

알고리즘을 키에 묶는다

실무에서 이 요구를 지키는 간단한 방법은 알고리즘을 토큰이 아니라 키 설정에 두는 것입니다. 아래는 그 구조를 보여 주는 설명용 의사 코드이고, 특정 라이브러리의 API가 아닙니다.

// 설명용 의사 코드입니다. 특정 라이브러리의 API가 아닙니다.
// kid → { 이 키에 허용된 단 하나의 알고리즘, 키 }
const trustedKeys = new Map([
  ["2026-10-a", { alg: "ES256", key: issuerPublicKeyA }],
]);

function verifyJwt(token) {
  const header = decodeProtectedHeader(token); // 아직 아무것도 믿지 않은 값
  const entry = trustedKeys.get(header.kid);   // 신뢰 목록 안에서만 찾는다
  if (!entry) return reject("unknown key");
  if (header.alg !== entry.alg) return reject("algorithm mismatch");
  if (!verifySignature(entry.alg, entry.key, token)) return reject("bad signature");
  // 서명이 맞은 뒤에 iss, aud, exp, nbf, typ를 확인한다
}

alg가 none이든 HS256이든, 키에 적힌 값과 다르면 서명 계산까지 가지 않습니다. 검증에 쓰는 알고리즘도 헤더가 아니라 entry.alg에서 나옵니다. 발급자가 키를 하나만 쓴다면 kid 없이 고정된 키 하나와 알고리즘 하나로 충분합니다.

키를 고를 때 쓰는 헤더도 신뢰할 수 없는 입력입니다.

  • kid는 어떤 키를 썼는지에 대한 힌트이고 구조가 정해져 있지 않습니다(RFC 7515 4.1.4절). 데이터베이스나 LDAP 조회에 쓴다면 SQL·LDAP 인젝션이 생기지 않게 검증하거나 정제해야 합니다(RFC 8725 3.10절).
  • jku(JWK Set URL)나 x5u(X.509 URL)를 그대로 따라가면 임의 URL로 요청을 보내게 되어 SSRF로 이어질 수 있습니다. RFC 8725 3.10절은 허용 목록과 대조하고 GET 요청에 쿠키를 보내지 않는 방법을 예로 듭니다(SHOULD).
  • 공개 키를 얻는 방식은 키의 출처를 인증해야 합니다(RFC 7515 10.3절). 토큰이 jwk 헤더에 스스로 실어 온 키는 다른 근거로 발급자의 키임을 확인하지 않는 한 출처가 확인되지 않은 키입니다.

HMAC을 쓴다면 키 자체도 문제입니다. RFC 7518 3.2절은 해시 출력 이상 크기의 키, 즉 HS256이면 256비트 이상을 요구합니다(MUST). RFC 8725 3.5절은 사람이 외울 수 있는 비밀번호를 HS256 같은 MAC 키로 직접 쓰면 안 된다고 정합니다(MUST NOT). 2.2절에 따르면 이런 약한 키는 공격자가 토큰 하나만 손에 넣어도 오프라인 무차별 대입이나 사전 공격의 대상이 됩니다. 계산한 HMAC과 받은 서명의 비교는 타이밍 공격을 막기 위해 상수 시간으로 해야 합니다(RFC 7518 3.2절, MUST).

서명이 맞은 다음에 확인할 것

서명이 맞다는 것은 누군가 이 키로 이 내용을 서명했다는 뜻까지입니다. RFC 7519 11.1절은 JWT의 내용이 암호로 보호되고 신뢰 판단에 필요한 맥락에 묶이지 않으면 믿을 수 없다고 적습니다.

  • iss: iss가 있으면 서명에 쓰인 키가 그 발급자의 것인지 확인하고, 아니면 거부해야 합니다(RFC 8725 3.8절, MUST). 키를 발급자에 묶는 방법은 애플리케이션마다 다르며, OpenID Connect는 발급자 메타데이터의 jwks_uri에서 키를 받아 옵니다.
  • sub: sub가 있으면 그 값이 애플리케이션에서 유효한 주체(또는 발급자·주체 쌍)인지 확인해야 합니다(3.8절, MUST).
  • aud: 한 발급자가 여러 수신자용 JWT를 발급한다면 aud가 있어야 하고, 수신자는 그 값이 없거나 자기와 관계없으면 거부해야 합니다(3.9절, MUST). 다른 서비스용으로 발급된 토큰을 돌려쓰는 대체 공격(2.7절)을 막는 장치입니다.
  • exp / nbf: exp 시각 이후, nbf 시각 이전에는 받아들이면 안 됩니다(RFC 7519 4.1.4·4.1.5절). 시계 오차를 위한 여유는 보통 몇 분 이내로 둘 수 있습니다(MAY).
  • typ: 같은 발급자가 여러 종류의 JWT를 만든다면 종류별 검증 규칙이 서로를 거부하도록 짜야 합니다(3.12절, MUST). 새로 설계하는 JWT라면 typ로 종류를 명시하는 것이 권장됩니다(3.11절, RECOMMENDED). 예를 들어 보안 이벤트 토큰은 secevent+jwt를 씁니다.

검증 체크리스트

  1. 허용 알고리즘을 설정에 고정하고, 헤더의 alg가 그 목록에 없으면 거부한다.
  2. 키마다 알고리즘을 하나만 정하고, alg가 그 키의 알고리즘과 같은지 확인한다.
  3. none은 기본으로 거부한다. 필요한 경우에만 그 객체에 한해 허용한다.
  4. 키는 신뢰할 수 있는 설정이나 발급자 메타데이터에서만 가져온다. kid는 정제하고 jku·x5u는 허용 목록과 대조한다.
  5. HMAC 키는 무작위로 만든 충분히 긴 값을 쓴다. HS256이면 256비트 이상이고 비밀번호는 쓰지 않는다.
  6. 서명이 맞은 뒤 iss와 키의 관계, sub, aud, exp·nbf, 필요하면 typ를 확인한다.
  7. 어느 단계에서든 실패하면 JWT 전체를 거부한다(RFC 7519 7.2절, RFC 8725 3.3절).

이것으로 막지 못하는 것

알고리즘을 고정하면 토큰이 검증 방법을 고르는 문제는 사라집니다. 다만 다음은 그대로 남습니다.

  • 유효한 토큰의 재사용: 정상적으로 발급된 토큰이 유출되면 서명은 계속 맞습니다. RFC 7515 10.10절은 고유 식별자를 넣고 이미 받은 적이 있는지 확인하는 방법을 언급하며, jti가 이 용도로 쓰일 수 있습니다(RFC 7519 4.1.7절).
  • 서명 키 유출: 개인 키나 MAC 키가 새면 공격자는 정상 발급자처럼 서명할 수 있습니다(RFC 7515 10.2절).
  • 내용의 비밀: 서명은 위조를 막을 뿐 페이로드를 숨기지 않습니다.
  • JWE와 중첩 JWT: 암호화된 JWT의 복호화 후 서명 검증 누락(RFC 8725 2.3절)이나 ECDH-ES 입력 검증(2.5절)은 이 글에서 다루지 않았습니다.

검증에 실패했을 때 어떤 상태 코드로 답할지는 HTTP 401과 403의 차이에 정리해 두었습니다.

참고한 문서

링크와 내용은 2026년 10월 10일에 확인했습니다.

블로그로