"alg=none" 공격 — 2026년에도 여전히 터지는 이유
JWT 명세(RFC 7519)는 토큰 헤더에 알고리즘 필드를 정의합니다. valid 값은 HS256, RS256, ES256, EdDSA — 그리고 "none"입니다. "none" 알고리즘은 토큰이 아예 서명되지 않았음을 뜻합니다. 원래 명분은 "JWS의 무결성이 다른 수단으로 이미 검증된 경우 유용"이었습니다 — 다른 레이어가 무결성을 보장하는 파이프라인용. 현실에서 그 각주는 현대 인증의 가장 인용되는 취약점 부류를 만들어냈습니다.
전형적 공격 시나리오 — 개발자가 JWT 라이브러리를 통합하고 verify 함수를 jwt.verify(token, secret) 식으로 호출합니다. 라이브러리는 헤더에 { "alg": "none" }이 들어 있으면 알고리즘을 보고 검증할 서명이 없다고 판단해 페이로드를 "verified"로 반환합니다. 공격자는 정상 토큰 하나를 받은 뒤 alg를 none으로 바꾸고 서명을 떼어, 누구로든 로그인할 수 있습니다. CVE 목록은 매년 새 항목이 추가됩니다 — CVE-2015-9235(jsonwebtoken), CVE-2018-1000531(inversoft), CVE-2019-7644(auth0/java-jwt), CVE-2022-23529(jsonwebtoken 재발생), 2024년까지 계속.
두 번째 변종은 "알고리즘 혼동" 공격입니다. RS256(비대칭)은 공개키로 서명을 검증합니다. HS256(대칭)은 공유 비밀로 검증합니다. 서버가 RS256 공개키를 저장해두고 verify 함수가 "key"라는 일반 파라미터를 받는다면, 공격자가 헤더를 alg: HS256으로 바꾸고 그 공개키를 HMAC 비밀처럼 써서 토큰에 서명합니다. 서버는 같은 키로 "검증"에 성공하고 위조 토큰을 받아들입니다. CVE-2016-10555, CVE-2019-20933 등 다수가 정확히 이 패턴입니다.
방어는 단순하지만 잊기 쉽습니다. 토큰의 alg 필드를 절대 신뢰하지 마세요. 검증자가 기대 알고리즘을 하드코딩(또는 엄격한 allowlist)하고 그 외(특히 "none")는 거부해야 합니다. 올바른 호출은 jwt.verify(token, key, { algorithms: ['RS256'] }) 같은 형태이고, 이 옵션이 실제 전달되고 있는지 코드에서 직접 확인해야 합니다. 2020년경부터 대부분 라이브러리가 안전한 allowlist를 기본값으로 두지만, 옵션 없이 검증자를 만드는 레거시 코드가 야생에 여전히 많습니다.
alg 외에도 검증자는 exp(만료 안 됨), nbf(이미 valid), iss(issuer 일치), aud(audience가 자신), 그리고 신경 쓰는 nonce / jti를 모두 체크해야 합니다. JWT는 서명된 JSON일 뿐 — 암호 서명은 바이트가 변하지 않았다는 것만 증명하지 그 바이트가 당신의 애플리케이션에 올바른 의미라는 걸 증명하지 않습니다.
// VULNERABLE — accepts alg=none and algorithm confusion
const payload = jwt.verify(token, key);
// HARDENED — explicit allowlist + claim checks
const payload = jwt.verify(token, key, {
algorithms: ['RS256'], // never trust header alg
issuer: 'https://api.example.com',
audience: 'https://app.example.com',
clockTolerance: 30, // 30s skew, not minutes
});
// alg=none token an attacker would craft
header = base64url('{"alg":"none","typ":"JWT"}')
payload = base64url('{"sub":"admin","exp":9999999999}')
signature = '' // empty
token = `${header}.${payload}.` // trailing dot, no sig
// Algorithm confusion: signing with the RS256 public key as HMAC key
const pub = fs.readFileSync('public.pem');
const forged = jwt.sign({ sub: 'admin' }, pub, { algorithm: 'HS256' });
// If the verifier doesn't pin algorithms, this passes.제대로 된 Refresh Token — Rotation, Reuse Detection, Sliding Session
Access token은 짧고(5~15분) bearer이며 DB 조회 없이 검증 가능합니다. Refresh token은 길고(며칠~몇 주) 새 access token과 교환되며, access token과 달리 즉시 폐기 가능해야 합니다. JWT 기반 시스템 대부분이 조용히 실패하는 곳이 refresh flow 설계입니다.
OAuth 2.1 draft와 IETF "OAuth 2.0 Security BCP"(RFC 9700, 2024) 모두 refresh token rotation을 의무화합니다 — 클라이언트가 refresh R로 새 access token을 받을 때마다 서버는 새 refresh R'를 발급하고 R을 무효화합니다. single-use 성질이 결정적입니다. rotation이 없다면 탈취된 refresh token은 무한 권한입니다. rotation이 있다면 R을 훔친 공격자는 한 번 쓰고, 정상 사용자 클라이언트가 다음에 refresh를 시도하는 순간 서버가 R의 두 번째 사용을 보고 — 무언가 잘못됐음을 알아채고 — 세션 패밀리 전체를 폐기할 수 있습니다.
이 "reuse detection"이 rotation의 진가입니다. 구현은 — 발급 refresh token마다 refresh-token family identifier(로그인 세션당 UUID)를 함께 저장. refresh를 받으면 옛 토큰을 used로 표시. 이미 used인 토큰으로 refresh 요청이 오면 (a) 정상 클라이언트가 네트워크 버그로 재시도 중이거나 (b) 누군가 토큰을 훔친 것입니다. 어느 쪽이든 안전한 동작은 같습니다 — 그 family의 모든 refresh token을 폐기, 신선한 로그인 강제, 보안 이벤트 기록. Auth0, Stripe, Supabase, 사실상 모든 주요 인증 공급자가 정확히 이 패턴입니다.
Sliding expiration이 두 번째 핵심. "refresh token은 발급 후 30일 만료" 같은 hard deadline 대신, max idle window("마지막 사용 후 14일 안에 써야 함")와 absolute lifetime("어쨌든 첫 발급 후 90일 만료") 두 가지를 둡니다. 이로써 "앱을 안 쓰기 전까지 로그인 유지"라는 UX와 "수년간 살아있는 refresh token"이라는 보안 호러를 분리할 수 있습니다.
저장에 관한 실무 노트 — refresh token은 JWT여서는 안 됩니다. JWT는 stateless / self-contained라 즉시 폐기가 필요한 것의 모양과 정반대입니다. Refresh token은 서버 측에 opaque 랜덤 문자열(Base64URL 32+ 랜덤 바이트)로 DB에 keyed 저장 — family ID, user ID, device fingerprint, 발급 시각, 마지막 사용 시각, used 플래그를 함께. Access token은 JWT 유지 — 충분히 짧아서 폐기가 덜 중요하고, 즉시 폐기는 최대 access token 수명만큼만 기다리면 됨. access도 진짜 즉시 폐기가 필요하면 JWT를 버리고 opaque session ID + 매 요청 서버 lookup으로 가야 — Stripe, GitHub, 대부분의 컨슈머 앱이 실제로 그렇게 합니다.
// Refresh endpoint with rotation + reuse detection
async function refresh(refreshToken) {
const row = await db.refreshTokens.findOne({ token: refreshToken });
if (!row) throw new Unauthorized();
if (row.used) {
// REPLAY: revoke the entire family
await db.refreshTokens.updateMany(
{ familyId: row.familyId },
{ revoked: true }
);
auditLog('refresh-token-replay', { userId: row.userId });
throw new Unauthorized('session compromised');
}
// Mark current as used + idle window check
if (Date.now() - row.lastUsedAt > 14 * DAY) throw new Unauthorized();
if (Date.now() - row.firstIssuedAt > 90 * DAY) throw new Unauthorized();
await db.refreshTokens.update({ id: row.id }, { used: true });
// Issue rotated pair
const newAccess = signJWT({ sub: row.userId }, { expiresIn: '15m' });
const newRefresh = base64url(crypto.randomBytes(32));
await db.refreshTokens.insert({
token: newRefresh,
familyId: row.familyId, // same family, new member
userId: row.userId,
used: false,
firstIssuedAt: row.firstIssuedAt, // PRESERVE absolute lifetime
lastUsedAt: Date.now(),
});
return { accessToken: newAccess, refreshToken: newRefresh };
}JWE vs JWS — 서명 말고 암호화가 필요한 순간
야생에서 보는 대부분의 "JWT"는 JWS — JSON Web Signature입니다. 페이로드는 Base64URL 인코딩될 뿐 암호화되지 않습니다 — 토큰을 가진 사람은 누구든 가운데 세그먼트를 base64 디코딩해 모든 claim을 읽을 수 있습니다. claim이 비민감("user 12345 로그인, X에 만료")이면 괜찮지만, 이메일·역할명·내부 user ID·요금제 등급 등 경쟁사나 규제 감사관이 읽으면 곤란한 정보가 들어 있다면 잘못된 선택입니다.
JWE — JSON Web Encryption — 은 JWS와 같은 아이디어에 페이로드 암호화를 더한 것입니다. JWE 토큰은 3 세그먼트가 아니라 5 세그먼트입니다(header, encrypted key, IV, ciphertext, auth tag). 페이로드 읽기에 복호화 키가 필요합니다. 민감 데이터를 담은 토큰을 제3자(웹훅 수신자, 리다이렉트 URL, 완전히 신뢰하지 않는 모바일 클라이언트) 거쳐 보내야 한다면 JWE가 정답입니다.
결정 규칙은 단순합니다 — 이 토큰의 보유자가 claim을 읽어야 하는가? 네 → JWS. 보유자가 자신의 백엔드 또는 자신의 클라이언트이고, 서명이 무결성 보장. 아니오 — 토큰이 내용을 알면 안 되는 코드를 통과하고 끝에서만 우리 서비스가 복호화 → JWE. 자주 쓰는 결합 패턴은 "nested JWT" — 먼저 JWS로 서명한 뒤 그것을 JWE의 plaintext로 wrap. 수신자는 암호화(자신만 읽음)와 서명(발급자 검증)을 모두 얻습니다.
2026년 알고리즘 선택 — JWS는 신규 시스템에 EdDSA(Ed25519) 추천. 가장 빠르고, 시그니처가 가장 작고, curve confusion 함정이 없음. ES256(ECDSA P-256)이 광범위 호환의 fallback. RS256은 RSA를 요구하는 PKI 환경에 묶여 있을 때만 — 키가 크고 서명이 느립니다. JWE는 대칭 셋업이면 직접 AES-GCM + key wrap(alg=A256KW + enc=A256GCM), 비대칭이면 ECDH-ES + AES-GCM. RSA-OAEP는 필수가 아니면 피하세요 — 느리고 키 크기가 가혹합니다.
마지막 경고 — 암호화는 "토큰에 민감 데이터를 아예 넣지 말라"의 대용품이 아닙니다. 토큰은 로깅되고, 캐싱되고, 복붙되고, 스크린샷됩니다. "claim은 검증자가 필요한 최소"라는 원칙은 JWS만큼 JWE에도 똑같이 적용됩니다. 새어나가면 안 되는 claim의 가장 안전한 자리는 DB이고, JWT는 opaque pointer만 들고 다니는 것이 정석입니다.
// JWS — payload is readable, just signed
header.payload.signature
^^^^^^^.^^^^^^^.^^^^^^^^^
3 segments, base64url decode the middle to read claims.
// JWE — payload is encrypted (5 segments)
header.encryptedKey.iv.ciphertext.tag
// Node 'jose' library
import { SignJWT, jwtVerify, EncryptJWT, jwtDecrypt } from 'jose';
// JWS sign with EdDSA (recommended new default)
const jws = await new SignJWT({ sub: '123' })
.setProtectedHeader({ alg: 'EdDSA' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(privateKey);
// JWE encrypt with AES-GCM
const jwe = await new EncryptJWT({ ssn: '123-45-6789' })
.setProtectedHeader({ alg: 'A256KW', enc: 'A256GCM' })
.setExpirationTime('5m')
.encrypt(symmetricKey);
// Nested: sign then encrypt
const inner = await new SignJWT({ sub: '123', email: '[email protected]' })
.setProtectedHeader({ alg: 'EdDSA' }).sign(signingKey);
const outer = await new EncryptJWT({ jwt: inner })
.setProtectedHeader({ alg: 'A256KW', enc: 'A256GCM' }).encrypt(encKey);