- Published on
JWT alg=none 취약점 재현·차단 체크리스트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 JWT를 검증할 때 alg 헤더를 신뢰하거나, 서명 검증 로직이 조건부로 빠지는 경우 alg=none 토큰이 “서명 없이도 유효”하게 처리되는 취약점이 발생할 수 있습니다. 이 이슈는 오래된 라이브러리/잘못된 설정/커스텀 인증 미들웨어에서 지금도 종종 재발합니다.
이 글은 다음을 목표로 합니다.
- 테스트 환경에서
alg=none공격을 재현하는 절차 - 애플리케이션 코드, 라이브러리 설정, 게이트웨이/프록시 계층에서 차단하는 체크리스트
- 배포 이후에도 재발을 막는 회귀 테스트와 관측 포인트
참고: JWT 검증 실패가
kid/JWKS 쪽 이슈인지, 알고리즘/서명 검증 로직 이슈인지 구분이 안 될 때는 JWT kid 누락·불일치로 JWKS 검증 실패 해결도 함께 보면 원인 분리가 빨라집니다.
1) alg=none 취약점이 생기는 전형적인 원인
1-1. 헤더의 alg를 “그대로” 신뢰
검증 코드가 토큰 헤더의 alg 값을 읽어 검증 방식을 분기하고, 그 값이 none이면 서명 검증을 스킵하는 형태입니다. 원래 JWT 스펙에서 none은 “서명하지 않음”을 의미하지만, 서버가 이를 허용하면 인증이 무력화됩니다.
1-2. 라이브러리 기본값/옵션 오용
- “검증 옵션을 비워두면 알아서 검증하겠지”라는 가정
verify대신decode를 사용- 알고리즘 허용 목록을 설정하지 않음
1-3. 커스텀 미들웨어에서 예외 처리로 우회
서명 검증 중 예외가 나면 “일단 통과”시키거나, 익명 사용자로 처리해야 하는데 권한을 유지하는 버그가 섞이면 실질적으로 우회가 됩니다.
2) 안전한 재현 환경 구성(필수)
실서비스에서 시도하면 법적/보안 이슈가 생길 수 있으니, 아래 조건을 만족하는 테스트 환경을 권장합니다.
- 로컬 또는 격리된 스테이징 환경
- 테스트용 계정/테스트용 리소스만 존재
- 액세스 로그/인증 로그를 남길 수 있음
- 재현 후 즉시 롤백 가능한 배포 파이프라인
3) alg=none 토큰 만들기(재현)
JWT는 header.payload.signature 형태이고, alg=none일 때는 서명 파트가 비어 있거나 빈 문자열처럼 처리되는 구현이 있습니다. 핵심은 헤더에 alg를 none으로 두고, payload에 원하는 클레임을 넣는 것입니다.
아래는 “서명 없이” JWT 형태를 만드는 예시입니다(테스트 목적).
3-1. Node.js로 토큰 조립(서명 없음)
// make-none-jwt.js
// 테스트 환경에서만 사용하세요.
function base64url(input) {
return Buffer.from(input)
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
const header = {
typ: 'JWT',
alg: 'none',
};
const payload = {
sub: 'victim-user-id',
role: 'admin',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 60 * 10,
};
const token = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}.`;
console.log(token);
실행:
node make-none-jwt.js
3-2. API 호출로 검증(취약 여부 확인)
TOKEN="$(node make-none-jwt.js)"
curl -i \
-H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/admin
취약한 경우 흔히 다음 중 하나가 관측됩니다.
200 OK로 보호된 리소스 접근 성공- 응답 바디에서
role=admin같은 클레임이 그대로 반영
안전한 경우는 보통 401 Unauthorized 또는 403 Forbidden이며, 서버 로그에 “서명 검증 실패” 또는 “허용되지 않는 알고리즘” 같은 메시지가 남습니다.
4) 차단 체크리스트(애플리케이션 코드)
아래는 “한 가지 조치”가 아니라 겹겹이 방어하는 관점의 체크리스트입니다.
4-1. decode와 verify를 혼동하지 않기
decode는 보통 서명 검증을 하지 않습니다.- 인증/인가 판단에는 반드시 “검증 함수”를 사용해야 합니다.
Node jsonwebtoken 예시
import jwt from 'jsonwebtoken';
export function verifyAccessToken(token) {
// 핵심: algorithms를 고정하고, none을 절대 허용하지 않기
return jwt.verify(token, process.env.JWT_PUBLIC_KEY, {
algorithms: ['RS256'],
audience: 'my-api',
issuer: 'https://issuer.example.com',
});
}
포인트:
algorithms허용 목록을 명시적으로 고정issuer,audience같은 컨텍스트 검증도 같이 수행
4-2. 알고리즘은 “헤더가 아니라 서버 설정”이 결정
취약한 구현은 “토큰 헤더의 alg를 읽고 그에 맞춰 검증”합니다. 올바른 구현은 “서버가 기대하는 알고리즘으로만 검증”합니다.
즉, 서버는 “이 API는 RS256만 받는다”처럼 계약을 가져야 합니다.
4-3. alg=none을 명시적으로 거부(방어적 코딩)
라이브러리가 안전하더라도, 방어적으로 한 번 더 거르는 것이 좋습니다.
function rejectNoneAlg(token) {
const [h] = token.split('.');
const headerJson = Buffer.from(h.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
const header = JSON.parse(headerJson);
if (header.alg === 'none') {
throw new Error('Rejected JWT with alg=none');
}
}
이 코드는 완전한 JWT 파서가 아니므로(패딩/에러 처리 등) 운영 코드에 그대로 쓰기보다는, WAF/게이트웨이 또는 테스트/탐지 목적의 “추가 방어선”으로 고려하세요.
4-4. 예외 처리로 인증을 통과시키지 않기
검증 중 예외가 발생하면 기본값은 “거부”여야 합니다.
- 실패 시
401로 종료 - 실패했는데도
req.user가 남아 있지 않도록 초기화
4-5. 만료/발급자/대상 검증을 함께 적용
alg=none만 막아도 되긴 하지만, 실전에서는 다음이 같이 빠져 있는 경우가 많습니다.
exp검증 누락iss/aud검증 누락nbf(Not Before) 무시
이들은 토큰 재사용/환경 혼용을 유발합니다.
5) 차단 체크리스트(인프라/게이트웨이/운영)
5-1. API Gateway / Reverse Proxy에서 사전 차단
가능하면 애플리케이션에 도달하기 전에 차단해 “실수의 여지”를 줄입니다.
Authorization헤더의 Bearer 토큰을 디코드해"alg":"none"패턴 탐지- JWT 검증을 게이트웨이에서 수행(지원되는 경우)
주의: 단순 문자열 매칭은 우회 가능성이 있으니(공백, 순서, 인코딩 등) “추가 방어선”으로 두고, 최종 검증은 서버에서 강제하세요.
5-2. 인증 실패 로깅/메트릭
다음 이벤트를 카운트하면 재발/공격 탐지에 도움이 됩니다.
alg=none거부 횟수- 서명 검증 실패 횟수
iss/aud불일치 횟수
5-3. 의존성(라이브러리) 고정 및 보안 업데이트
- JWT 라이브러리 버전을 고정하고, 보안 패치 릴리즈를 정기 반영
- “예전 코드 복붙”으로 오래된 사용법이 남아있지 않은지 점검
6) 회귀 테스트(재발 방지) 예시
CI에서 alg=none 토큰을 넣었을 때 반드시 401이 나오는지 테스트로 고정해두면, 추후 리팩터링/라이브러리 교체 때도 안전합니다.
6-1. Jest + Supertest 예시
import request from 'supertest';
import app from '../app';
function base64url(input) {
return Buffer.from(input)
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
function makeNoneJwt(payload) {
const header = { typ: 'JWT', alg: 'none' };
return `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}.`;
}
test('rejects alg=none JWT', async () => {
const token = makeNoneJwt({ sub: 'x', role: 'admin', exp: Math.floor(Date.now() / 1000) + 600 });
const res = await request(app)
.get('/api/admin')
.set('Authorization', `Bearer ${token}`);
expect([401, 403]).toContain(res.status);
});
이 테스트가 통과한다는 것은 최소한 “서명 없는 토큰으로 보호 자원 접근”이 막혔다는 뜻입니다.
7) 현장 점검용 최종 체크리스트
아래 항목을 “예/아니오”로 빠르게 점검하세요.
- 인증 로직에서
decode결과로 권한 결정을 하지 않는다 - 검증 시 허용 알고리즘 목록을 명시한다(예:
RS256만) - 토큰 헤더의
alg를 신뢰해 검증 방식을 바꾸지 않는다 -
alg=none은 명시적으로 거부한다(추가 방어선) - 서명 검증 실패/예외 발생 시 기본 동작이 “거부”다
-
iss,aud,exp검증을 강제한다 - 게이트웨이/WAF에서
alg=none탐지/차단 룰을 추가했다(선택) -
alg=none회귀 테스트가 CI에 포함되어 있다 - 인증 실패 메트릭/로그로 이상 징후를 관측한다
8) 자주 헷갈리는 포인트
8-1. “토큰이 3조각이면 JWT니까 검증된 것”이 아니다
JWT는 단지 포맷일 뿐이고, 검증은 서버가 수행합니다. 특히 alg=none은 “서명 파트가 빈 토큰”도 그럴듯한 JWT처럼 보이게 만듭니다.
8-2. OAuth/OIDC를 쓰면 자동으로 안전한가?
OIDC를 써도, 리소스 서버에서 ID 토큰/액세스 토큰을 잘못 검증하면 동일한 문제가 납니다. 리다이렉트/상태값 문제 등 OAuth 설정 이슈가 섞이면 디버깅이 어려워질 수 있으니, 인증 플로우 이슈는 OAuth redirect_uri 불일치 400 원인·해결 7가지 같은 체크리스트와 분리해 접근하는 게 좋습니다.
마무리
alg=none 취약점은 “JWT를 쓴다”는 사실만으로는 막히지 않고, 검증 구현의 디테일에서 갈립니다. 가장 중요한 원칙은 단순합니다.
- 알고리즘은 서버가 정한다
- 검증 실패는 무조건 거부한다
- 회귀 테스트로 계속 확인한다
위 재현 절차로 스테이징에서 한 번 검증해두고, 차단 체크리스트를 코드/설정/CI에 반영하면 같은 유형의 인증 우회를 장기적으로 줄일 수 있습니다.