전체 목록
보안Medium#85

JWT(JSON Web Token)의 구조와 보안상 주의해야 할 점을 설명해주세요.

#보안#JWT#인증#토큰
힌트

서명 검증과 민감한 정보 저장 여부를 생각해보세요.

정답 및 해설

JWT(JSON Web Token)의 구조와 보안상 주의해야 할 점을 설명해주세요.

JWT(JSON Web Token)는 당사자 간에 정보를 JSON 형식으로 안전하게 전송하기 위한 개방형 표준(RFC 7519)입니다. 주로 인증(Authentication)과 권한 부여(Authorization)에 사용되며, 서버가 별도의 세션 저장소 없이도 토큰 자체에 사용자 정보를 담아 stateless하게 인증을 처리할 수 있습니다.

JWT의 구조

JWT는 점(.)으로 구분된 세 부분으로 구성됩니다.

Header.Payload.Signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Header (헤더)

알고리즘 종류와 토큰 타입을 담고 있으며, Base64URL로 인코딩됩니다.

{
  "alg": "HS256",
  "typ": "JWT"
}

주요 알고리즘:

알고리즘종류설명
HS256대칭키 (HMAC)하나의 비밀키로 서명 및 검증
RS256비대칭키 (RSA)개인키로 서명, 공개키로 검증
ES256비대칭키 (ECDSA)RS256보다 짧은 키로 동등한 보안
none없음절대 사용 금지 - 서명 없음

Payload (페이로드)

실제 전달하려는 데이터(클레임)를 담고 있으며, Base64URL로 인코딩됩니다.

{
  "sub": "1234567890",
  "name": "홍길동",
  "email": "hong@example.com",
  "role": "USER",
  "iat": 1516239022,
  "exp": 1516242622
}

클레임은 세 가지로 분류됩니다:

종류예시설명
등록된 클레임 (Registered)iss, sub, aud, exp, iatRFC 7519에 사전 정의된 표준 클레임
공개 클레임 (Public)email, name충돌 방지를 위해 URI 형태로 정의 권장
비공개 클레임 (Private)role, userId당사자 간 합의된 커스텀 클레임

Signature (서명)

Header와 Payload를 비밀키로 서명한 값으로, 토큰 위변조 여부를 검증합니다.

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secretKey
)
// 서명 생성 과정 (개념적 설명)
const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const payload = base64UrlEncode(JSON.stringify({ sub: "123", exp: 1234567890 }));
const signature = HMACSHA256(`${header}.${payload}`, SECRET_KEY);
const jwt = `${header}.${payload}.${signature}`;

JWT 인증 흐름

클라이언트                          서버
   |                                  |
   |--- POST /login (id, password) -->|
   |                                  | 자격증명 검증
   |<--- JWT 토큰 발급 ---------------|
   |                                  |
   |--- GET /api/profile              |
   |    Authorization: Bearer <JWT> ->|
   |                                  | 토큰 서명 검증
   |                                  | Payload에서 사용자 정보 추출
   |<--- 사용자 데이터 ---------------|

보안상 주의해야 할 점

1. Payload는 누구나 읽을 수 있다

Base64URL은 암호화가 아니라 인코딩입니다. 브라우저 콘솔에서 즉시 디코딩할 수 있습니다.

// 누구나 Payload를 디코딩할 수 있음
const payload = "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IuaxoOqZqOuLpCIsInJvbGUiOiJVU0VSIn0";
console.log(atob(payload));
// {"sub":"1234567890","name":"홍길동","role":"USER"}

절대로 Payload에 포함하면 안 되는 정보:

  • 비밀번호
  • 신용카드 번호
  • 주민등록번호
  • 민감한 개인정보

2. alg: none 공격 방지

일부 초기 JWT 라이브러리는 alg: none으로 설정된 토큰을 서명 없이 유효한 것으로 처리하는 취약점이 있었습니다.

// 공격자가 위조한 토큰 예시
const maliciousHeader = base64UrlEncode({ alg: "none", typ: "JWT" });
const maliciousPayload = base64UrlEncode({ sub: "admin", role: "ADMIN" });
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.`; // 서명 없음

방어 방법: 서버에서 허용할 알고리즘을 명시적으로 지정

// jsonwebtoken (Node.js) 사용 예시
const jwt = require('jsonwebtoken');

// 나쁜 예: 알고리즘 미검증
jwt.verify(token, SECRET_KEY);

// 좋은 예: 허용 알고리즘 명시
jwt.verify(token, SECRET_KEY, { algorithms: ['HS256'] });
// Java (jjwt) 사용 예시
Jwts.parserBuilder()
    .requireAlgorithm("HS256")  // 허용 알고리즘 명시
    .setSigningKey(secretKey)
    .build()
    .parseClaimsJws(token);

3. 짧은 만료 시간 + Refresh Token 패턴

JWT는 발급 후 서버에서 무효화가 어렵기 때문에, 탈취되더라도 피해를 최소화하기 위해 짧은 만료 시간을 설정합니다.

// Access Token: 짧은 만료 시간 (15분 ~ 1시간)
const accessToken = jwt.sign(
  { sub: userId, role: userRole },
  ACCESS_SECRET,
  { expiresIn: '15m' }
);

// Refresh Token: 긴 만료 시간 (7일 ~ 30일), DB에 저장
const refreshToken = jwt.sign(
  { sub: userId },
  REFRESH_SECRET,
  { expiresIn: '7d' }
);

Refresh Token 흐름:

클라이언트                          서버
   |--- /login ---------------------->|
   |<-- Access Token (15분)          |
   |    Refresh Token (7일) ---------|
   |                                  |
   | [15분 후 Access Token 만료]      |
   |                                  |
   |--- /auth/refresh (Refresh Token)->|
   |                                  | Refresh Token DB 검증
   |<-- 새 Access Token --------------|

4. 안전한 토큰 저장 (XSS 방어)

토큰 저장 위치에 따른 보안 비교:

저장 위치XSS 공격CSRF 공격권장 여부
localStorage취약 (JS 접근 가능)안전비권장
sessionStorage취약 (JS 접근 가능)안전비권장
HttpOnly Cookie안전 (JS 접근 불가)취약권장
HttpOnly + SameSite Cookie안전안전최권장
// 서버에서 HttpOnly 쿠키로 토큰 설정
res.cookie('accessToken', accessToken, {
  httpOnly: true,    // JavaScript 접근 차단 (XSS 방어)
  secure: true,      // HTTPS에서만 전송
  sameSite: 'strict', // CSRF 방어
  maxAge: 15 * 60 * 1000 // 15분
});

5. 강력한 비밀키 사용

// 나쁜 예: 약한 비밀키
const SECRET = "secret";
const SECRET = "password123";

// 좋은 예: 충분한 엔트로피를 가진 랜덤 키
const crypto = require('crypto');
const SECRET = crypto.randomBytes(64).toString('hex');
// 환경 변수로 관리
const SECRET = process.env.JWT_SECRET;

6. 토큰 무효화 전략

JWT의 stateless 특성상 발급된 토큰을 즉시 무효화하기 어렵습니다.

// 방법 1: 블랙리스트 (Redis 활용)
async function logout(token) {
  const decoded = jwt.decode(token);
  const ttl = decoded.exp - Math.floor(Date.now() / 1000);

  // 만료 시간만큼 Redis에 블랙리스트 등록
  await redis.setex(`blacklist:${token}`, ttl, 'true');
}

async function verifyToken(token) {
  const isBlacklisted = await redis.get(`blacklist:${token}`);
  if (isBlacklisted) throw new Error('Token has been revoked');

  return jwt.verify(token, SECRET_KEY, { algorithms: ['HS256'] });
}

// 방법 2: 토큰 버전 관리 (DB에 사용자별 토큰 버전 저장)
const token = jwt.sign(
  { sub: userId, tokenVersion: user.tokenVersion },
  SECRET_KEY
);

JWT vs Session 비교

항목JWT (Stateless)Session (Stateful)
서버 저장소불필요필요 (DB, Redis 등)
확장성높음 (서버 추가 용이)세션 공유 필요
토큰 무효화어려움즉시 가능
네트워크 부하토큰 크기만큼 증가세션 ID만 전송
모바일 친화성높음쿠키 의존적

정리

JWT를 안전하게 사용하기 위한 체크리스트:

  • Payload에 민감한 정보 포함 금지
  • 서버에서 허용 알고리즘 명시적 검증 (alg: none 방지)
  • Access Token 만료 시간을 짧게 설정 (15분 ~ 1시간)
  • Refresh Token으로 재발급 패턴 구현
  • HttpOnly + Secure + SameSite 쿠키에 저장
  • 강력한 비밀키 사용 및 환경 변수 관리
  • 필요 시 블랙리스트 또는 토큰 버전으로 즉시 무효화