Published on

JWT alg=none·kid 인젝션 30분 차단 체크리스트

Authors

서버 인증을 JWT로 굴리다 보면 “서명 검증은 하고 있으니 안전하다”는 착각이 자주 나옵니다. 하지만 alg=none 우회(서명 자체를 생략하게 만드는 설정 실수)와 kid 인젝션(키 선택 로직을 악용해 공격자가 원하는 키로 검증을 유도)은 구현/운영 습관에서 반복적으로 터지는 고전 취약점입니다.

이 글은 “완벽한 JWT 아키텍처”가 아니라, 30분 안에 현실적으로 차단할 수 있는 조치를 우선순위대로 제공합니다. 이미 Nginx나 API Gateway에서 JWT를 검증 중이라면 함께 확인할 수 있도록 게이트웨이 레벨 방어도 포함합니다.

관련해서 Nginx에서 JWT 검증이 꼬일 때 디버깅 포인트는 이 글도 같이 보면 좋습니다: Nginx JWT 검증 401? auth_jwt 설정과 디버깅

1) 공격 시나리오를 3분만에 이해하기

alg=none 우회가 왜 생기나

JWT 헤더의 alg는 “서명 알고리즘”을 뜻합니다. 원칙적으로 서버는 토큰을 받을 때 서버가 허용한 알고리즘만 검증해야 합니다. 그런데 과거 일부 라이브러리/예제 코드에서 “헤더가 말하는 알고리즘을 그대로 신뢰”하거나, none을 허용하는 옵션이 켜져 있으면 서명 없이도 통과할 수 있습니다.

공격자는 다음처럼 서명을 비워 둔 토큰을 만들어 관리자 권한을 주장할 수 있습니다.

  • 헤더: {"alg":"none","typ":"JWT"}
  • 페이로드: {"sub":"admin","role":"admin"}
  • 서명: 없음

kid 인젝션은 어떤 류의 문제인가

kid는 키 식별자입니다. 서버가 여러 공개키를 운영하는 경우, 헤더의 kid로 “어떤 키로 검증할지”를 선택합니다.

문제는 키 선택 로직이 다음 중 하나면 터집니다.

  • kid를 파일 경로로 사용해 로컬 파일을 읽음
  • kid를 URL로 사용해 원격에서 키를 가져옴(SSRF)
  • kid를 DB 조회 키로 쓰면서 인젝션/캐시 오염
  • kid 값이 너무 길거나 특수문자 포함인데 검증 없이 통과

즉, kid는 “메타데이터”인데, 이를 입력값처럼 다루면 공격 표면이 열립니다.

2) 30분 차단 플랜: 우선순위 6단계

아래 순서대로 하면 “급한 불”을 빠르게 끌 수 있습니다.

1단계(5분): 허용 알고리즘을 서버에서 고정

가장 먼저 할 일은 “토큰 헤더의 alg를 신뢰하지 않는다”입니다.

  • 서버 설정에 HS256 또는 RS256허용 리스트를 하드코딩
  • none은 무조건 거부
  • 가능하면 typJWT만 허용(필수는 아니지만 방어에 도움)

Node.js 예시(jsonwebtoken)

아래처럼 algorithms를 지정하면, 헤더가 뭐라고 주장하든 허용된 알고리즘만 검증합니다.

import jwt from 'jsonwebtoken';

export function verifyAccessToken(token, publicKey) {
  return jwt.verify(token, publicKey, {
    algorithms: ['RS256'],
    // audience, issuer도 가능하면 고정
    audience: 'api',
    issuer: 'https://auth.example.com',
  });
}

포인트는 algorithms를 빼먹지 않는 것입니다.

2단계(5분): kid를 “조회 키”가 아니라 “매핑 키”로 제한

kid를 그대로 파일 경로나 URL로 쓰지 말고, 서버가 가진 키셋에서 매핑만 하세요.

  • kid는 정규식으로 제한(예: 영숫자, -, _ 정도)
  • 길이 제한(예: 1~64)
  • 매핑 실패 시 즉시 401

안전한 키 선택 의사코드

const KID_RE = /^[A-Za-z0-9_-]{1,64}$/;

function selectKeyByKid(kid: string, jwksCache: Map<string, string>) {
  if (!KID_RE.test(kid)) throw new Error('invalid kid');
  const key = jwksCache.get(kid);
  if (!key) throw new Error('unknown kid');
  return key;
}

여기서 핵심은 “kid로 네트워크/파일 I O를 하지 않는다”입니다.

3단계(5분): JWKS는 고정된 출처에서만 가져오고, 캐시한다

현대적인 RS256 운영에서는 JWKS를 씁니다. 이때 kid 인젝션이 SSRF로 이어지는 흔한 패턴은 “kid에 따라 URL을 바꾸는” 구현입니다.

  • JWKS URL은 서버 설정으로 고정
  • kid는 JWKS 내부에서만 매칭
  • JWKS는 주기적으로 갱신하되, 요청마다 원격 호출하지 않기

Node.js 예시(jose + 원격 JWKS)

josecreateRemoteJWKSet는 JWKS URL을 고정하고 내부적으로 캐시합니다.

import { jwtVerify, createRemoteJWKSet } from 'jose';

const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));

export async function verify(token) {
  const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
    issuer: 'https://auth.example.com',
    audience: 'api',
    algorithms: ['RS256'],
  });

  // protectedHeader.kid 는 로깅용으로만 취급
  return payload;
}

4단계(5분): 서명 검증 외 “클레임 검증”을 필수로

alg=nonekid만 막아도 큰 효과가 있지만, 실제 침해는 보통 “클레임 검증 부실”과 같이 옵니다.

최소한 아래는 강제하세요.

  • exp 만료 필수
  • iss 발급자 고정
  • aud 대상 고정
  • 필요 시 nbf와 허용 clock skew 제한

Java 예시(nimbus-jose-jwt)

JWSAlgorithm expectedAlg = JWSAlgorithm.RS256;

ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
JWKSource<SecurityContext> keySource = new RemoteJWKSet<>(new URL("https://auth.example.com/.well-known/jwks.json"));
JWSKeySelector<SecurityContext> keySelector = new JWSVerificationKeySelector<>(expectedAlg, keySource);
jwtProcessor.setJWSKeySelector(keySelector);

JWTClaimsSet claims = jwtProcessor.process(token, null);

if (!"https://auth.example.com".equals(claims.getIssuer())) throw new BadJWTException("bad iss");
if (!claims.getAudience().contains("api")) throw new BadJWTException("bad aud");
if (new Date().after(claims.getExpirationTime())) throw new BadJWTException("expired");

주의할 점은 “알고리즘 기대값”을 코드에서 고정하는 것입니다.

5단계(5분): 게이트웨이에서 1차 차단 룰 추가

애플리케이션 배포가 느리거나, 여러 서비스가 JWT를 제각각 검증한다면 게이트웨이에서 1차로 걸러 피해 반경을 줄일 수 있습니다.

예시 룰(개념)

  • 헤더 base64 디코드 후 "alg":"none" 포함 시 차단
  • kid 길이가 비정상적으로 길면 차단
  • 토큰 세그먼트가 2개인 JWT(서명 없는 형태) 차단

Nginx auth_jwt를 쓰는 경우에도 “서명 없는 JWT”나 “비정상 헤더”를 사전에 걸러두면 디버깅이 쉬워집니다. 관련 디버깅은 위 내부 링크 글을 참고하세요.

6단계(5분): 관측과 대응(로그, 알람, 롤백 플랜)

차단은 끝이 아니라 시작입니다. 실제로 공격이 들어오면 다음이 필요합니다.

  • JWT 검증 실패 사유를 구조화 로그로 남김(단, 토큰 원문은 저장하지 않기)
  • kid 값은 길이 제한 후 샘플링 로깅
  • 401 폭증 알람

운영 장애 대응 패턴(재시도/서킷브레이커 등)은 API 호출 쪽이지만, “폭증을 어떻게 다루는가” 관점에서 아래 글의 사고방식이 도움됩니다: Claude API 529·429 재시도 전략과 구현 패턴

3) 현장에서 자주 보는 취약 구현 패턴과 교정

패턴 A: kid를 파일명으로 사용

// 취약 예시
const pubKey = fs.readFileSync(`/keys/${kid}.pem`, 'utf8');

문제점

  • kid에 경로 조작이 섞이면 임의 파일 읽기 위험
  • 키 파일 존재 여부로 정보 노출

교정

  • kid는 매핑 테이블로만 사용
  • 파일 접근이 필요하면 “서버가 허용한 kid 목록”에서만 선택
const allowed = {
  'key-2025-01': '/keys/key-2025-01.pem',
  'key-2025-02': '/keys/key-2025-02.pem',
};

const path = allowed[kid];
if (!path) throw new Error('unknown kid');
const pubKey = fs.readFileSync(path, 'utf8');

패턴 B: 헤더의 alg로 검증기를 선택

// 취약 예시(개념)
if (header.alg === 'HS256') verifyWithHmac(token, secret);
else if (header.alg === 'RS256') verifyWithRsa(token, publicKey);
else if (header.alg === 'none') return payload;

교정

  • 서버 정책을 먼저 결정하고, 토큰은 그 정책에 맞는지만 확인
  • “지원 알고리즘을 늘리는 것”은 공격 표면을 늘리는 일

패턴 C: HS256과 RS256을 혼용하면서 키를 재사용

혼용 자체가 항상 취약하진 않지만, 같은 문자열을 HMAC 시크릿과 RSA 공개키처럼 다루는 실수는 위험합니다.

교정

  • HS256은 전용 시크릿(충분한 엔트로피)
  • RS256은 공개키/개인키 페어
  • 서비스별로 “한 가지 방식”으로 단순화하는 것이 안전

4) 30분 점검 체크리스트(복사해서 티켓으로 쓰기)

  • JWT 검증 시 algorithms 허용 리스트를 서버에서 고정했고 none은 거부한다
  • kid는 정규식과 길이 제한을 통과해야 하며, 매핑 실패 시 즉시 거부한다
  • kid로 파일 경로/URL/쿼리를 만들지 않는다
  • JWKS URL은 설정으로 고정되어 있고, 요청마다 원격 호출하지 않는다
  • iss, aud, exp 검증이 필수이며 누락 시 실패한다
  • 401 급증, unknown kid, invalid kid 이벤트를 알람으로 올린다

5) 마무리: “JWT는 문자열”이 아니라 “정책”이다

alg=nonekid 인젝션은 최신 취약점이라기보다, 정책을 토큰에게 맡길 때 생기는 전형적인 사고입니다. 서버가 “우리는 RS256만 받는다”, “kid는 이 목록에서만 고른다”를 선언하고 강제하면 대부분 1차 차단이 됩니다.

만약 지금 운영 중인 시스템에서 Nginx나 게이트웨이에서 JWT 검증을 하고 있고 401이 자주 튄다면, 설정/로그 포인트를 정리한 글을 함께 보면서 빠르게 원인을 좁혀가세요: Nginx JWT 검증 401? auth_jwt 설정과 디버깅