JSON Web Token은 합리적인 형식이지만 날카로운 모서리가 길게 늘어서 있습니다. 문제가 되는 것은 토큰 자체가 아닌 경우가 대부분입니다. 버그는 그것을 어떻게 검증하는지, 어디에 저장하는지, 얼마나 오래 유효한지, 사용자가 로그아웃할 때 무슨 일이 일어나는지에 있습니다. 이 실수들의 결과는 모두 같습니다. 누군가 가져서는 안 될 토큰을 들고 있고, 여러분의 서버가 그것을 받아들이는 것이죠.
가장 자주 등장하는 두 가지 약점은 암호학적 서명의 부적절한 검증인 CWE-347과, 불충분한 세션 만료인 CWE-613입니다. 아래 예제는 Node.js와 엣지 런타임, 브라우저에서 동작하는 널리 쓰이는 JavaScript JWT 라이브러리인 jose를 사용합니다.
디코딩은 검증이 아닙니다
CWE-347의 가장 직접적인 형태는 서명을 확인하지 않고 토큰에서 클레임을 읽는 것입니다. 모든 JWT 라이브러리에는 디버깅용 디코드 함수가 있고, 그것이 인증 미들웨어에 등장하는 일이 생각보다 잦습니다. 디코딩된 토큰은 누구나 만들 수 있는 base64일 뿐입니다. JavaScript 코드베이스에서는 토큰을 점으로 나눠 가운데 부분에 JSON.parse를 돌리는 작은 헬퍼 안에 이 패턴이 숨어 있는 경우가 많습니다. 테스트 토큰은 유효하므로 모든 테스트에서 잘 동작하고, 프로덕션에서는 위조된 모든 토큰을 받아들입니다.
import { decodeJwt, jwtVerify } from 'jose';
// 취약: 누구나 sub에 임의의 사용자 ID를 넣은 토큰을 만들 수 있습니다
const claims = decodeJwt(token);
const userId = claims.sub;
// 올바름: 서명과 알고리즘, 클레임을 먼저 검사합니다
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
const verifiedUserId = payload.sub;alg none과 알고리즘 혼동
JWT 헤더는 어떤 알고리즘으로 서명했는지를 담고 있는데, 그 헤더는 공격자가 제어합니다. 이를 신뢰하는 데서 두 가지 고전적인 공격이 나옵니다. 첫째는 alg를 none으로 설정하는 것으로, 서명되지 않은 토큰을 일부 구형 라이브러리가 유효한 것으로 받아들였습니다. 둘째는 알고리즘 혼동입니다. RS256을 기대하면서 헤더가 알고리즘을 고르게 두는 서버는, 서버의 공개 키를 HMAC 시크릿으로 사용해 서명한 HS256 토큰을 받게 될 수 있습니다. 공개 키는 공개되어 있으므로 공격자는 무엇이든 서명할 수 있습니다.
최신 라이브러리는 두 공격 모두를 방어하며, jose는 jwtVerify에서 서명되지 않은 토큰을 거부하고 키 타입이 알고리즘과 맞는지 확인합니다. 그래도 기본값에만 의존하지 마세요. 모든 verify 호출에 알고리즘 목록을 명시적으로 고정해, 이후의 리팩터링이나 라이브러리 교체가 조용히 범위를 넓히지 못하게 하세요. 키 마이그레이션 중처럼 둘 이상의 알고리즘을 받아야 한다면 둘 다 명시하고 각각 별도의 키를 쓰세요. 키를 교체할 때는 헤더에 kid를 넣고 토큰 헤더에 적힌 URL이 아니라 여러분이 가진 목록에서 kid로 키를 선택하세요.
약한 서명 시크릿
HS256 토큰은 공유 시크릿으로 서명합니다. 시크릿이 짧거나 추측하기 쉽다면, 예컨대 'secret'이나 앱 이름, 튜토리얼에서 복사한 값이라면, 유효한 토큰 하나만 가진 공격자가 흔한 도구로 오프라인에서 무차별 대입해 알아낸 뒤 아무 사용자의 토큰이나 서명할 수 있습니다. 오프라인 추측에는 속도 제한이 없습니다.
- HS256에는 최소 32바이트의 난수를 쓰세요. openssl rand -base64 32 같은 명령으로 생성할 수 있습니다.
- 시크릿을 환경 변수나 시크릿 매니저에서 읽고, 없거나 짧으면 시작 시점에 실패하게 하세요.
- 절대 커밋하지 말고, 한 번이라도 커밋되었다면 교체하세요.
- 여러 서비스가 토큰을 검증해야 하지만 발급은 한 곳만 해야 한다면, 검증자가 공개 키만 갖도록 RS256이나 EdDSA 같은 비대칭 알고리즘을 쓰세요.
exp, aud, iss를 검증하세요
유효한 서명은 누가 토큰을 발급했는지만 증명합니다. 그것이 여러분을 위한 것인지, 아직 유효한지는 클레임이 결정합니다. 만료가 없는 토큰은 영원히 유효합니다. 모바일 API용으로 발급된 토큰이 단지 같은 신원 제공자를 신뢰한다는 이유로 관리자 서비스에서 받아들여져서는 안 됩니다. aud와 iss가 바로 그것을 위한 것입니다.
import { SignJWT, jwtVerify } from 'jose';
const rawSecret = process.env.JWT_SECRET;
if (!rawSecret || rawSecret.length < 32) {
throw new Error('JWT_SECRET must be set and at least 32 characters');
}
const secret = new TextEncoder().encode(rawSecret);
const ISSUER = 'https://api.example.com';
const AUDIENCE = 'https://app.example.com';
export function signAccessToken(userId: string, tokenVersion: number) {
return new SignJWT({ tv: tokenVersion })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(userId)
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setIssuedAt()
.setExpirationTime('10m')
.sign(secret);
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: ISSUER,
audience: AUDIENCE,
requiredClaims: ['exp', 'sub'],
});
return payload;
}jose는 exp가 있으면 항상 검사하지만, exp가 없는 토큰은 그대로 통과했을 것입니다. requiredClaims 옵션이 그 틈을 막아 줍니다. 외부 신원 제공자가 발급한 토큰이라면 createRemoteJWKSet으로 제공자가 게시한 키 세트에 대해 검증하되, 알고리즘과 발급자, 대상은 여전히 고정하세요.
localStorage냐 httpOnly 쿠키냐
토큰을 localStorage에 저장하면 페이지의 모든 스크립트가 읽을 수 있게 됩니다. XSS 버그 하나, 혹은 손상된 서드파티 스크립트 하나면 토큰이 공격자에게 전달되어 만료될 때까지 어디서든 사용될 수 있습니다.
httpOnly 쿠키는 JavaScript가 읽을 수 없습니다. XSS는 여전히 심각합니다. 주입된 스크립트가 페이지가 열려 있는 동안 사용자로서 요청을 보낼 수 있기 때문입니다. 하지만 수명이 긴 자격 증명을 훔쳐 나중에 재사용할 수는 없습니다. 자체 백엔드와 통신하는 브라우저 앱이라면 쿠키가 더 나은 기본값입니다. 별도의 API를 호출하는 싱글 페이지 앱이라면 액세스 토큰은 메모리에만 두고 리프레시 토큰을 httpOnly 쿠키에 두는 것이 합리적인 절충안입니다.
// Express: 안전한 속성을 가진 세션 쿠키
res.cookie('__Host-session', accessToken, {
httpOnly: true, // JavaScript에서 읽을 수 없습니다
secure: true, // HTTPS 전용, __Host- 접두사에 필요합니다
sameSite: 'lax', // 크로스 사이트 POST에는 전송되지 않습니다
path: '/', // __Host- 접두사에 필요합니다
maxAge: 10 * 60 * 1000, // 밀리초 단위, 토큰 수명과 일치합니다
});__Host- 접두사는 쿠키가 Secure이고 path가 /이며 Domain 속성이 없지 않으면 거부하라고 브라우저에 알려 주며, 이로써 손상된 서브도메인이 쿠키를 덮어쓰는 것을 막습니다.
SameSite가 막아 주지 않는 것
SameSite=Lax는 크로스 사이트 POST 요청과 하위 리소스 로드에서 쿠키를 차단해 전통적인 CSRF 대부분을 없애 줍니다. 그래도 최상위 GET 내비게이션에서는 쿠키를 보내므로, 상태를 바꾸는 GET 엔드포인트는 노출됩니다. Strict는 그것까지 막지만, 이메일이나 다른 사이트의 링크를 따라 앱에 들어온 사용자를 로그아웃 상태로 보이게 만듭니다. None은 보호를 끄며 Secure를 요구합니다.
Lax에 더해 GET 요청은 결코 상태를 바꾸지 않는다는 규칙이면 건전한 기준선이 됩니다. 민감한 변경에는 Origin 헤더 검사나 CSRF 토큰을 추가하세요. SameSite는 등록 가능 도메인의 모든 서브도메인을 동일 사이트로 취급하므로, 취약한 서브도메인 하나가 여전히 요청을 위조할 수 있다는 점을 기억하세요.
회전, 폐기, 그리고 진짜 로그아웃
상태를 갖지 않는 JWT는 만료되기 전에 폐기할 수 없습니다. 요청마다 데이터베이스를 확인하지 않는 대신 치르는 대가죠. CWE-613은 그 대가를 무시했을 때 벌어지는 일을 설명합니다. 실제로 접근을 끝내지 못하는 로그아웃과 비밀번호 변경, 계정 정지 같은 것들입니다. 계정 삭제와 역할 변경, 팀에서의 제외도 모두 폐기 사건이며, 각각은 다음 토큰 만료 시점이 아니라 다음 요청에서 효력을 가져야 합니다.
- 액세스 토큰의 수명을 며칠이 아니라 몇 분 단위로 짧게 유지하세요.
- 리프레시 토큰은 해시해서 서버에 저장하고, 사용할 때마다 회전시키세요.
- 이미 회전된 리프레시 토큰이 다시 사용되면 탈취로 간주하고 해당 토큰 계열 전체를 폐기하세요.
- 사용자 행과 토큰에 토큰 버전을 추가하고, 전체 로그아웃이나 비밀번호 변경, 정지 시에 올리세요.
- 세션 고정을 막기 위해 로그인 시 세션 식별자를 재생성하세요.
export async function requireUser(token: string) {
const payload = await verifyAccessToken(token);
const user = await db.user.findUnique({
where: { id: payload.sub },
select: { id: true, tokenVersion: true, disabled: true },
});
// 버전이 올라갔거나 계정이 비활성화되면 즉시 접근이 끝납니다
if (!user || user.disabled || user.tokenVersion !== payload.tv) {
throw new Error('Session revoked');
}
return user;
}이 조회는 요청마다 데이터베이스 읽기를 한 번 되살리는데, 그것이 폐기를 위해 치러야 할 정직한 비용입니다. 많은 앱은 쿠키에 불투명한 세션 ID를 두고 세션 테이블을 쓰는 편이 더 단순하고 안전합니다. 그러면 로그아웃은 행 하나를 삭제하는 일이 됩니다. JWT는 튜토리얼의 기본값이어서가 아니라, 서비스 간의 수명이 짧은 토큰처럼 그 특성이 실제로 도움이 되는 곳에 쓰세요.
무엇을 선택하든 로그아웃은 서버에서 일어나야 합니다. 브라우저에서 쿠키를 지우거나 토큰을 삭제하는 것은 사용자의 사본만 없앨 뿐, 탈취된 사본은 서버가 거부할 때까지 계속 동작합니다. 로그아웃 시에는 서버 측 세션이나 리프레시 토큰을 삭제하고, 설정할 때 쓴 것과 같은 이름과 경로, 속성으로 쿠키를 지우고, 사용자가 전체 로그아웃을 선택했다면 토큰 버전을 올리세요. 비밀번호 재설정도 다른 모든 세션을 종료해야 합니다.
짧은 리뷰 체크리스트
- 인증 경로에서 decode 호출을 검색하세요.
- 모든 verify 호출이 알고리즘과 발급자, 대상을 고정하고 exp를 요구하는지 확인하세요.
- 서명 시크릿이 어떻게 생성되고 로드되며 시작 시점에 검증되는지 확인하세요.
- 토큰이 브라우저의 어디에 저장되는지 찾으세요.
- 로그아웃과 비밀번호 변경, 계정 정지가 기존 세션을 종료하는지 테스트하세요.
이 검사들은 대체로 코드 경로를 끝까지 읽어 내려가는 일이며, CodeAuditAgent가 감사에 접근하는 방식이기도 합니다. 발견 사항에는 인용한 코드 줄과 CWE, 공격 시나리오, 패치가 함께 제공되므로 누락된 대상 검증을 몇 분 안에 확인하고 고칠 수 있습니다.