우리 서비스의 JWT 토큰 만료시간이 얼마인지 아는가? 24시간이었다. '사용자 편의를 위해'라는 이유로. 보안팀에서 모의 해킹을 돌렸을 때, 탈취된 토큰 하나로 24시간 동안 뭘 할 수 있는지 보여줬다. 그날 회의실 분위기는... 말하지 않겠다.
흔한 JWT 보안 실수들
1. 하드코딩된 Secret Key
가장 기본적이면서도 자주 발견되는 실수입니다:
// ❌ 절대 하지 마세요
public class JwtUtils {
private static final String SECRET = "mySecretKey123";
// ...
}
문제점:
- 소스 코드에 시크릿이 노출됨
- Git 히스토리에 영구 기록
- 환경별 키 분리 불가
올바른 방법:
// ✅ 환경변수 또는 외부 설정에서 주입
@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 구현 시 반드시 확인할 항목들:
- ☐ Secret Key가 환경변수 또는 외부 설정에서 로드되는가?
- ☐ Secret Key 길이가 최소 256bit 이상인가?
- ☐ 알고리즘이 명시적으로 지정되어 있는가?
- ☐ 적절한 만료 시간이 설정되어 있는가?
- ☐ Payload에 민감 정보가 포함되지 않았는가?
- ☐ Refresh Token 메커니즘이 구현되어 있는가?
- ☐ 토큰 탈취 시 무효화 방법이 있는가?
보안 사고를 겪고 나서야 깨달은 것들
보안은 "잘 동작하면 된다"가 아닙니다. 작은 실수가 전체 시스템을 위험에 빠뜨릴 수 있습니다. 특히 인증 관련 코드는 반드시 코드 리뷰와 보안 테스트를 거쳐야 합니다.
Jaeseong
10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.
GitHub →
💬 댓글