우리 서비스의 JWT 토큰 만료시간이 얼마인지 아는가? 24시간이었다. '사용자 편의를 위해'라는 이유로. 보안팀에서 모의 해킹을 돌렸을 때, 탈취된 토큰 하나로 24시간 동안 뭘 할 수 있는지 보여줬다. 그날 회의실 분위기는... 말하지 않겠다.

JWT 인증 흐름 아키텍처 클라이언트 브라우저/앱 사용자 인터페이스 Spring Boot 서버 Security Filter Chain JWT 토큰 검증 필터 JwtTokenProvider 토큰 생성/검증/파싱 UserDetailsService 사용자 인증 정보 조회 Access Token 만료: 30분 Refresh Token 만료: 7일 PasswordEncoder BCrypt 비밀번호 검증 데이터베이스 사용자 테이블 ID, 비밀번호(해시), 권한 토큰 블랙리스트 무효화된 토큰 관리 1. 로그인 요청 (ID + 비밀번호) 2. 사용자 조회 3. JWT 토큰 발급 (Access + Refresh) 4. API 요청 (Authorization: Bearer ...) 5a. 검증 성공 → 응답 5b. 검증 실패 → 401 보안 체크리스트 ✓ Secret Key 외부 관리 ✓ 256bit 이상 키 길이 ✓ 알고리즘 명시적 지정 ✓ 적절한 만료 시간 ✓ Payload 최소 정보 ✓ Refresh Token 전략 ✓ 토큰 무효화 방법

흔한 JWT 보안 실수들

1. 하드코딩된 Secret Key

가장 기본적이면서도 자주 발견되는 실수입니다:

// ❌ 절대 하지 마세요
public class JwtUtils {
    private static final String SECRET = "mySecretKey123";
    // ...
}

문제점:

올바른 방법:

// ✅ 환경변수 또는 외부 설정에서 주입
@Component
public class JwtUtils {
    @Value("${jwt.secret}")
    private String jwtSecret;
    
    // 또는 환경변수 직접 사용
    // private String jwtSecret = System.getenv("JWT_SECRET");
}

2. 약한 Secret Key

// ❌ 너무 짧거나 예측 가능한 키
jwt.secret=password123

// ✅ 충분히 긴 랜덤 키 (최소 256bit)
jwt.secret=a3K9mP2xR7vB4nQ8wE5tY0uI6oL1sD3fG8hJ...

키 생성 방법:

// Java에서 안전한 키 생성
SecureRandom random = new SecureRandom();
byte[] keyBytes = new byte[32]; // 256 bits
random.nextBytes(keyBytes);
String secret = Base64.getEncoder().encodeToString(keyBytes);

3. Algorithm None 취약점

JWT 라이브러리 중 일부는 알고리즘을 "none"으로 설정한 토큰을 허용합니다:

// ❌ 취약한 검증
public Claims parseToken(String token) {
    return Jwts.parser()
        .setSigningKey(secret)
        .parseClaimsJws(token)
        .getBody();
}

// ✅ 알고리즘 명시적 검증
public Claims parseToken(String token) {
    return Jwts.parserBuilder()
        .setSigningKey(getSigningKey())
        .setAllowedClockSkewSeconds(60)
        .build()
        .parseClaimsJws(token)
        .getBody();
}

4. 민감 정보 저장

JWT의 payload는 Base64로 인코딩될 뿐, 암호화되지 않습니다:

// ❌ 민감 정보 포함
{
  "sub": "user123",
  "password": "userPassword",  // 절대 안됨!
  "ssn": "123-45-6789",        // 주민번호 등
  "creditCard": "1234-5678..." // 카드번호
}

// ✅ 최소한의 정보만 포함
{
  "sub": "user123",
  "roles": ["USER"],
  "exp": 1234567890
}

5. 부적절한 만료 시간

// ❌ 만료 시간 없음 또는 너무 긴 만료
.setExpiration(null)  // 영구 토큰
.setExpiration(Date.from(Instant.now().plus(365, ChronoUnit.DAYS)))  // 1년

// ✅ 적절한 만료 시간 설정
// Access Token: 15분 ~ 1시간
.setExpiration(Date.from(Instant.now().plus(30, ChronoUnit.MINUTES)))

// Refresh Token: 7일 ~ 30일 (별도 관리)
.setExpiration(Date.from(Instant.now().plus(7, ChronoUnit.DAYS)))

Spring Security에서 발견한 실수

PasswordEncoder 인자 순서

이것은 실제 코드 리뷰에서 발견한 치명적 버그입니다:

// ❌ 인자 순서가 뒤바뀜 - 항상 false 반환
if (passwordEncoder.matches(user.getPassword(), rawPassword)) {
    // 로그인 성공 처리
}

// ✅ 올바른 순서: (평문, 암호화된 값)
if (passwordEncoder.matches(rawPassword, user.getPassword())) {
    // 로그인 성공 처리
}

matches(CharSequence rawPassword, String encodedPassword) - 첫 번째가 평문, 두 번째가 인코딩된 값입니다.

권장 구현 패턴

JWT 서비스 클래스

@Service
@Slf4j
public class JwtTokenProvider {
    
    @Value("${jwt.secret}")
    private String jwtSecret;
    
    @Value("${jwt.access-token-expiration-ms}")
    private long accessTokenExpirationMs;
    
    @Value("${jwt.refresh-token-expiration-ms}")
    private long refreshTokenExpirationMs;
    
    private Key getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);
        return Keys.hmacShaKeyFor(keyBytes);
    }
    
    public String generateAccessToken(UserDetails userDetails) {
        return Jwts.builder()
            .setSubject(userDetails.getUsername())
            .claim("roles", userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()))
            .setIssuedAt(new Date())
            .setExpiration(new Date(System.currentTimeMillis() + accessTokenExpirationMs))
            .signWith(getSigningKey(), SignatureAlgorithm.HS256)
            .compact();
    }
    
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            log.warn("Invalid JWT token: {}", e.getMessage());
            return false;
        }
    }
}
실무 경험 공유: 보안 점검에서 JWT 토큰 만료시간이 너무 길다고 지적받은 경험이 있습니다. 편의상 만료시간을 길게 설정했었는데, 점검 결과를 보고 Access Token 만료시간을 15분으로 줄이고 Refresh Token 로테이션을 도입했습니다. UX에는 영향 없이 보안성을 개선할 수 있었고, 보안은 항상 "만약에"를 가정하고 설계해야 한다는 걸 배웠습니다.

체크리스트

JWT 구현 시 반드시 확인할 항목들:

보안 사고를 겪고 나서야 깨달은 것들

보안은 "잘 동작하면 된다"가 아닙니다. 작은 실수가 전체 시스템을 위험에 빠뜨릴 수 있습니다. 특히 인증 관련 코드는 반드시 코드 리뷰와 보안 테스트를 거쳐야 합니다.

Jaeseong
Jaeseong

10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.

GitHub →

💬 댓글