- Published on
JWT alg=none·kid 인젝션 취약점 재현·차단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 JWT를 “서명된 토큰”으로 전제하고 검증 로직을 단순화하면, 공격자는 헤더의 alg 또는 kid 값을 교묘히 조작해 인증을 우회할 수 있습니다. 특히 alg=none(서명 미검증)과 kid 인젝션(키 선택 로직 악용)은 과거 여러 구현체에서 반복적으로 등장한 패턴입니다.
이 글에서는 두 취약점을 로컬에서 재현 가능한 형태로 설명하고, 실무에서 바로 적용할 수 있는 차단(방어) 체크리스트를 제공합니다. 예시는 교육 목적으로 단순화했으며, 실제 운영 환경에서는 라이브러리/프레임워크의 권장 설정을 최우선으로 따르세요.
관련해서 “출력 형식 강제” 같은 보안 프롬프트 설계가 필요하다면 RAG 환각을 줄이는 JSON Schema 강제 출력법도 참고할 만합니다. (보안은 결국 “허용 가능한 입력만 받는 것”이라는 점에서 통합니다.)
JWT 검증에서 자주 깨지는 전제
JWT는 보통 다음 전제를 기반으로 설계합니다.
- 토큰은
header.payload.signature3부분으로 구성된다 - 서버는 허용한 알고리즘만으로 서명을 검증한다
- 서버는 신뢰 가능한 키로만 검증한다
- 헤더 파라미터(
kid,jku,x5u등)는 “편의”일 뿐, 신뢰 경계 밖이다
문제는 구현 단계에서 다음과 같은 실수가 섞일 때 발생합니다.
alg를 토큰 헤더에서 그대로 받아 “그 알고리즘으로 검증”해버림alg=none을 허용하거나,none일 때 검증을 건너뜀kid로 파일/DB/캐시에서 키를 찾는데, 입력 검증이 느슨함kid가 URL/파일 경로/SQL/헤더 주입으로 이어지는 경로가 존재함
취약점 1: alg=none 우회
동작 원리
alg=none은 “서명 없음”을 의미합니다. 정상적인 서버라면 alg=none 토큰을 거부해야 합니다. 하지만 취약한 구현은 아래처럼 동작할 수 있습니다.
- 헤더에
alg=none이 오면, 서명 검증을 생략하고 payload만 신뢰 - 혹은 라이브러리 옵션/레거시 코드 때문에
none을 허용
그 결과, 공격자는 원하는 payload(예: role=admin)를 넣고 서명 없이 통과시킬 수 있습니다.
재현용 토큰 만들기(개념)
JWT는 Base64URL 인코딩을 사용합니다. alg=none 토큰은 서명 파트가 비어 있거나(구현체에 따라) 마지막 점(.)만 붙는 형태가 됩니다.
예시 헤더/페이로드:
{"alg":"none","typ":"JWT"}
{"sub":"victim","role":"admin","iat":1700000000}
이를 Base64URL로 인코딩해 다음 형태를 만들면 됩니다.
base64url(header).base64url(payload).
주의: 본문에 부등호를 그대로 쓰면 MDX에서 JSX로 오인될 수 있으므로, 화살표나 제네릭 표기는 반드시 인라인 코드로 감쌉니다. 예를 들어 a -> b 같은 표기도 a -> b로 처리하세요.
취약한 검증 코드 예시(의도적으로 나쁜 예)
아래는 “헤더의 alg를 그대로 신뢰”하고, none이면 검증을 건너뛰는 전형적인 실수입니다.
// 나쁜 예시: 교육용. 실제로 이렇게 구현하면 안 됩니다.
function verifyJwtBad(token) {
const [h, p, s] = token.split(".");
const header = JSON.parse(Buffer.from(h, "base64url").toString("utf8"));
const payload = JSON.parse(Buffer.from(p, "base64url").toString("utf8"));
if (header.alg === "none") {
// 취약점: 서명 검증 생략
return payload;
}
// ... header.alg에 따라 검증 로직 선택(이 또한 위험)
// verifyWithAlg(header.alg, h + "." + p, s)
return payload;
}
차단 전략
핵심은 “알고리즘은 서버 정책”이어야 한다는 점입니다.
- 토큰 헤더의
alg를 신뢰하지 말고, 서버가 허용한 알고리즘만 사용 none은 무조건 거부- 가능하면 라이브러리에서
algorithmsallowlist를 강제
Node.js jsonwebtoken 계열이라면(예시):
import jwt from "jsonwebtoken";
function verifyJwtStrict(token, publicKey) {
return jwt.verify(token, publicKey, {
algorithms: ["RS256"], // 서버 정책으로 고정
complete: false
});
}
Java 계열(JJWT, Nimbus 등)도 동일하게 “허용 알고리즘 고정”이 1순위입니다. 프레임워크가 제공하는 검증 필터/미들웨어를 쓰고, 커스텀 파서로 헤더를 먼저 읽어 분기하는 코드는 피하세요.
취약점 2: kid 인젝션
kid란 무엇인가
kid는 Key ID로, 여러 키 중 어떤 키로 서명을 검증해야 하는지 힌트를 주는 헤더입니다.
{"alg":"RS256","typ":"JWT","kid":"key-2026-01"}
정상적인 설계에서는 서버가 kid를 보고 “미리 등록된 키셋”에서 키를 찾아 검증합니다. 문제는 kid가 입력값이라는 점입니다. 즉, 키 조회 로직이 파일/DB/네트워크와 연결되면 공격 표면이 급격히 커집니다.
대표적인 kid 인젝션 유형
아래는 실무에서 자주 거론되는 패턴입니다.
경로 조작(Path Traversal)
kid를 파일명으로 사용: 예)keys/${kid}.pem- 공격:
kid에../등을 넣어 임의 파일을 읽게 시도
헤더/개행 주입
kid를 HTTP 헤더나 로그에 그대로 넣어 2차 피해 유발
SQL 인젝션
kid로 DB에서 키를 조회할 때 문자열 결합 쿼리 사용
SSRF/원격 키 로딩
kid또는jku를 URL로 취급해 원격에서 JWKS를 가져오는 설계- 공격자가 내부망으로 요청을 유도하거나, 악성 키셋을 주입
이 글의 주제는 kid이므로, “파일 기반 키 로딩”과 “JWKS 기반 키 선택”에서의 방어 포인트를 집중적으로 보겠습니다.
취약한 코드 예시 1: 파일 경로에 kid를 결합
// 나쁜 예시: kid를 파일 경로로 직접 결합
import fs from "node:fs";
function loadKeyBad(kid) {
// 취약점: kid에 "../" 등이 들어가면 경로 조작 위험
const path = `./keys/${kid}.pem`;
return fs.readFileSync(path, "utf8");
}
차단
kid를 파일 경로로 쓰지 말고, 매핑 테이블(allowlist) 로만 선택- 꼭 파일을 써야 한다면,
kid정규식 검증 + 디렉터리 고정 + 실경로 검증
import fs from "node:fs";
import path from "node:path";
const KEY_MAP = {
"key-2026-01": "key-2026-01.pem",
"key-2026-02": "key-2026-02.pem"
};
function loadKeyStrict(kid) {
if (!Object.hasOwn(KEY_MAP, kid)) {
throw new Error("unknown kid");
}
const baseDir = path.resolve("./keys");
const filePath = path.resolve(baseDir, KEY_MAP[kid]);
// baseDir 밖으로 나가면 차단
if (!filePath.startsWith(baseDir + path.sep)) {
throw new Error("invalid key path");
}
return fs.readFileSync(filePath, "utf8");
}
핵심은 “kid는 식별자일 뿐 경로가 아니다”를 코드로 강제하는 것입니다.
취약한 코드 예시 2: kid로 원격 JWKS를 가져오는 설계
현대 서비스는 보통 JWKS(JSON Web Key Set)를 사용합니다. 이때 잘못된 구현은 kid 또는 jku를 근거로 임의 URL에서 키셋을 가져오게 만들 수 있습니다.
- 서버가 토큰 헤더의
jku를 신뢰 jku가 가리키는 JWKS를 fetch- 그 JWKS에 들어있는 공격자 키로 서명 검증 통과
차단
jku,x5u같은 “키 위치 힌트”는 기본적으로 무시- JWKS URL은 서버 설정으로 고정(issuer별 allowlist)
iss(issuer)와 JWKS endpoint를 1:1로 묶고, 토큰의iss도 검증- 캐시/핀닝 적용: 허용된 키만 신뢰하고, 키 회전 정책을 명시
예시(개념 코드):
// 좋은 방향(개념): issuer별로 고정된 JWKS만 사용
const ISSUER_CONFIG = {
"https://auth.example.com": {
jwksUrl: "https://auth.example.com/.well-known/jwks.json",
algorithms: ["RS256"],
audience: "api://my-service"
}
};
function getIssuerConfig(iss) {
const cfg = ISSUER_CONFIG[iss];
if (!cfg) throw new Error("untrusted issuer");
return cfg;
}
실제로는 검증 라이브러리(예: OAuth/OIDC 미들웨어)가 제공하는 issuer 검증, JWKS 캐싱, 키 선택 로직을 그대로 활용하는 편이 안전합니다.
실무 차단 체크리스트
아래는 alg=none과 kid 인젝션을 함께 막기 위한 점검 항목입니다.
1) 알고리즘 정책 고정
alg는 서버 정책으로 고정(allowlist)none거부- 대칭키(HS256)와 비대칭키(RS256/ES256)를 혼용하지 말고, 서비스 단위로 명확히 분리
2) 키 선택 로직 단순화
kid는 “키셋에서의 인덱스”로만 사용kid로 파일 경로/URL/쿼리를 만들지 않기- 키 저장소는 가능하면 메모리/안전한 KMS/검증된 JWKS 캐시 사용
3) issuer, audience, time claim 검증
서명만 맞으면 끝이 아닙니다.
iss검증: 신뢰하는 발급자만 허용aud검증: 토큰이 이 API를 대상으로 발급되었는지 확인exp,nbf,iat검증: 만료/유효 시작/발급 시각 점검- clock skew 허용 범위를 최소화
4) 키 회전과 폐기 전략
- 키 회전 시
kid가 바뀌는 정책을 문서화 - 폐기된
kid는 즉시 거부 - JWKS 캐시 TTL과 강제 갱신 조건(예: 서명 실패 시 재조회)을 정의
5) 로깅/모니터링
alg=none시도, 알 수 없는kid요청을 보안 이벤트로 로깅- 같은 IP/토큰 패턴의 반복 시도에 대한 rate limit 적용
- 인증 실패 원인을 과도하게 상세히 응답하지 않기
운영 중 장애 대응 관점에서 “체크리스트 기반 복구”는 큰 도움이 됩니다. 보안 사고/장애 후 변경 이력 정리가 필요하다면 Git rebase 후 force push 충돌 복구 체크리스트처럼 체크리스트 접근을 추천합니다.
간단 재현 실험: 취약/안전 검증 비교
아래는 “취약한 검증”과 “안전한 검증”의 차이를 로컬에서 빠르게 확인하는 데 도움이 되는 형태입니다.
1) 취약한 서버(개념)
import express from "express";
const app = express();
app.get("/admin", (req, res) => {
const token = (req.headers.authorization || "").replace("Bearer ", "");
try {
const payload = verifyJwtBad(token); // 앞서의 나쁜 예시
if (payload.role === "admin") return res.json({ ok: true });
return res.status(403).json({ ok: false });
} catch {
return res.status(401).json({ ok: false });
}
});
app.listen(3000);
이 경우 alg=none 토큰으로 role=admin을 넣으면 통과할 여지가 생깁니다.
2) 안전한 서버(개념)
import express from "express";
import jwt from "jsonwebtoken";
const app = express();
const publicKey = process.env.JWT_PUBLIC_KEY;
app.get("/admin", (req, res) => {
const token = (req.headers.authorization || "").replace("Bearer ", "");
try {
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"],
audience: "api://my-service",
issuer: "https://auth.example.com"
});
if (payload.role === "admin") return res.json({ ok: true });
return res.status(403).json({ ok: false });
} catch {
return res.status(401).json({ ok: false });
}
});
app.listen(3000);
여기서는 alg=none 자체가 허용되지 않고, iss/aud까지 맞아야 하며, 키 선택도 서버가 통제합니다.
자주 하는 질문과 실수
kid가 없으면 어떻게 하나
- 단일 키만 쓰는 서비스라면
kid없이도 검증할 수 있습니다. - 다중 키를 쓴다면
kid는 필요할 수 있지만, 반드시 “등록된 키셋에서만 선택”되게 하세요.
왜 토큰 헤더를 신뢰하면 안 되나
헤더는 공격자가 마음대로 바꿀 수 있는 입력입니다. “서명 검증 전에” 헤더를 보고 로직을 바꾸면, 검증이 무력화되는 방향으로 설계가 기울기 쉽습니다.
라이브러리 쓰면 끝 아닌가
라이브러리 자체는 안전해도, 다음 순간 취약해질 수 있습니다.
- 검증 옵션을 느슨하게 설정
- 커스텀 키 로더를 붙이며
kid를 경로/URL로 사용 - issuer/audience 검증을 생략
즉, 라이브러리는 도구이고, 정책(allowlist)과 신뢰 경계가 핵심입니다.
결론
alg=none취약점은 “서명 없는 토큰을 받아들이는지”의 문제이며, 해결책은 알고리즘 allowlist 고정과none거부입니다.kid인젝션은 “키 선택 과정이 입력에 오염되는지”의 문제이며, 해결책은kidallowlist 매핑, 경로/URL/쿼리로의 직접 연결 차단, issuer 기반의 고정된 JWKS 정책입니다.
JWT는 편리하지만, 검증 단계의 작은 유연함이 곧 취약점이 됩니다. 검증 로직을 최대한 단순하게 유지하고, “허용한 것만 통과”하도록 정책을 코드와 설정으로 강제하세요.