- Published on
JWT alg=none·kid 인젝션 30분 차단 체크리스트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 인증을 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은 무조건 거부- 가능하면
typ도JWT만 허용(필수는 아니지만 방어에 도움)
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)
jose의 createRemoteJWKSet는 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=none과 kid만 막아도 큰 효과가 있지만, 실제 침해는 보통 “클레임 검증 부실”과 같이 옵니다.
최소한 아래는 강제하세요.
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=none과 kid 인젝션은 최신 취약점이라기보다, 정책을 토큰에게 맡길 때 생기는 전형적인 사고입니다. 서버가 “우리는 RS256만 받는다”, “kid는 이 목록에서만 고른다”를 선언하고 강제하면 대부분 1차 차단이 됩니다.
만약 지금 운영 중인 시스템에서 Nginx나 게이트웨이에서 JWT 검증을 하고 있고 401이 자주 튄다면, 설정/로그 포인트를 정리한 글을 함께 보면서 빠르게 원인을 좁혀가세요: Nginx JWT 검증 401? auth_jwt 설정과 디버깅