JWT: 올바른 사용법과 흔한 보안 오류
1. 개요
JWT(JSON Web Token)는 API 인증 곳곳에서 사용되지만, 많은 시스템이 잘못 구현해서 심각한 보안 허점이 생깁니다. 이 글에서는 Spring Boot에서 JWT를 올바르게 생성하고 검증하는 방법과, alg confusion, 약한 secret, 만료 기간 미검사 등의 고전적인 보안 오류와 대책을 정리했습니다. 이 글을 읽고 나면 JWT를 안전하게 설정하는 방법을 알게 되고, 동료 코드의 JWT 부분을 더 자신감 있게 리뷰할 수 있을 겁니다.
2. JWT 구조와 작동 방식
JWT는 header.payload.signature 형태로 base64url로 인코딩되어 점으로 구분됩니다.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.4Z... (signature)
- Header:
alg(서명 알고리즘)과typ을 포함합니다. - Payload:
sub,exp,iat,roles같은 claim을 포함합니다. - Signature: 토큰이 변조되지 않았는지 검증하는 서명입니다.
주의: JWT는 payload를 암호화하지 않고 인코딩만 합니다. 토큰만 있으면 누구나 내용을 읽을 수 있습니다. claim에 비밀번호나 민감한 개인정보를 넣지 마세요.
3. 의존성 추가
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
Java에서는 jjwt(io.jsonwebtoken) 라이브러리가 가장 널리 쓰이며, API가 명확하고 흔한 오류를 기본적으로 차단합니다.
4. 토큰을 올바르게 생성하고 검증하기
4.1. 충분히 강력한 secret key 생성
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
public class JwtKeyFactory {
public static SecretKey generateKey() {
return Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
}
}
Keys.secretKeyFor는 HS256을 위한 충분한 길이(≥ 256 비트)의 무작위 key를 생성합니다. 프로덕션 환경에서는 이 key를 secret manager(Vault, AWS Secrets Manager)에 저장하고, 코드에 하드코딩하면 안 됩니다.
4.2. TokenService로 토큰 생성
import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;
import java.time.Instant;
import java.util.Date;
public class TokenService {
private final SecretKey key;
public TokenService(SecretKey key) {
this.key = key;
}
public String generateToken(String username) {
Instant now = Instant.now();
return Jwts.builder()
.subject(username)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(900))) // 15분
.signWith(key)
.compact();
}
}
expiration은 15분으로 설정합니다 — access token은 짧게 살아야 합니다.signWith(key)는 key의 종류에 맞는 알고리즘을 자동으로 선택해서, 클라이언트가alg를 조작할 여지를 없앱니다.
4.3. 토큰 검증
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;
public class TokenVerifier {
private final SecretKey key;
public TokenVerifier(SecretKey key) {
this.key = key;
}
public Claims verify(String token) {
try {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (JwtException e) {
throw new InvalidTokenException("토큰이 유효하지 않거나 만료되었습니다", e);
}
}
}
parseSignedClaims는 자동으로 서명과 exp를 검증합니다. 서명이 틀리거나 토큰이 만료되면 JwtException을 던집니다 — 이를 받아서 애플리케이션 레벨 exception으로 변환합니다.
5. 보안 오류 #1: alg=none 또는 alg confusion
공격자가 헤더를 {"alg":"none"}으로 수정하고 서명 없는 토큰을 보낼 수 있습니다. 백엔드가 알고리즘을 강제하지 않으면 가짜 토큰을 받아들일 수 있습니다.
// 잘못됨: 알고리즘을 지정하지 않음
Jwts.parser().build().parseSignedClaims(token); // 구식 라이브러리는 속기 쉬움
방어 방법 — 항상 허용할 알고리즘을 화이트리스트로 제한합니다:
import io.jsonwebtoken.Jwts;
Jwts.parser()
.verifyWith(key) // 알려진 key만 허용
.build()
.parseSignedClaims(token); // jjwt 0.12+ 이상에서는 alg=none을 자동으로 차단
참고: 다른 JWT 라이브러리(jose4j, nimbus-jose-jwt)를 쓸 때는 항상 명시적으로 허용 알고리즘을 설정하세요(예:
HS256만 허용). 클라이언트가 보낸 토큰의alg필드를 믿고 그에 따라 검증 방식을 바꾸면 안 됩니다.
더 위험한 변종: RS256과 HS256 사이의 alg confusion. 서버가 RSA 공개 키로 검증하는데 코드에서 alg=HS256일 때 그 공개 키를 HMAC의 secret으로 쓴다면, 공격자는 공개 key(누구나 접근 가능)로 토큰에 서명할 수 있습니다. 해결책: 사용 사례에 맞게 알고리즘을 고정하고, 토큰의 alg 필드를 읽어서 검증 방식을 동적으로 선택하면 안 됩니다.
6. 보안 오류 #2: 약한 secret이나 노출된 secret
// 잘못됨: secret이 짧고 brute-force에 취약함
String secret = "mysecret123";
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());
HS256은 최소 256비트(32바이트) 이상의 key를 요구합니다. 짧은 secret은 hashcat 같은 도구로 오프라인 brute-force 공격에 몇 시간 안에 뚫릴 수 있습니다.
// 올바름: secret이 충분히 길고 무작위임
SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
그 외:
- Secret을 Git에 커밋하지 마세요. 환경 변수나 secret manager를 사용하세요.
- Secret을 정기적으로 교체하세요. 특히 secret에 접근할 권한이 있던 직원이 나갈 때.
# application.properties - 환경 변수에서 읽고, 하드코딩하지 말 것
jwt.secret=${JWT_SECRET}
7. 보안 오류 #3: 만료 기간 미검사 또는 토큰이 너무 오래 유지됨
// 잘못됨: access token이 30일 동안 유지됨
.expiration(Date.from(now.plus(30, ChronoUnit.DAYS)))
Access token이 너무 오래 살아있다는 것은, 토큰이 유출되면 공격자가 오래도록 권한을 가질 수 있고, 당신은 이를 revoke할 방법이 없다는 뜻입니다(JWT는 stateless이므로 기본적으로 logout 메커니즘이 없습니다).
올바른 패턴: access token은 짧게(5–15분) + refresh token은 더 길게(서버에 저장해서 revoke 가능).
public class TokenPair {
private final String accessToken; // 15분 유지
private final String refreshToken; // 7일 유지, DB에 저장해서 revoke 가능
// constructor, getters
}
public class RefreshTokenRepository {
public boolean isRevoked(String refreshTokenId) {
// DB/Redis에서 조회, 토큰이 logout되었으면 true 반환
return false;
}
}
Refresh token이 revoke되면 기존 access token은 여전히 만료할 때까지 유효합니다 — 이 때문에 access token은 짧아야 합니다.
8. 보안 오류 #4: 민감한 데이터를 payload에 넣기
// 잘못됨: payload는 공개이므로 토큰을 가진 누구나 읽을 수 있음
.claim("password", user.getPassword())
.claim("creditCard", user.getCardNumber())
Payload는 base64로만 인코딩되고 암호화되지 않습니다. jwt.io 같은 사이트로 디코딩하면 모든 내용이 보입니다. Authorization에 필요한 claim만 넣으세요: sub, roles, exp, scope.
// 올바름
.subject(user.getUsername())
.claim("roles", user.getRoles())
9. 보안 오류 #5: 여러 서비스가 같은 secret을 쓸 때 issuer/audience 미검증
여러 서비스가 같은 secret으로 토큰에 서명하면, 서비스 A용으로 발급된 토큰이 aud(audience)를 검증하지 않으면 서비스 B에서도 재사용될 수 있습니다.
import io.jsonwebtoken.Jwts;
Claims claims = Jwts.parser()
.verifyWith(key)
.requireAudience("order-service") // 이 서비스용 토큰만 허용
.requireIssuer("auth-server")
.build()
.parseSignedClaims(token)
.getPayload();
requireAudience와 requireIssuer는 claim이 일치하지 않으면 parser가 exception을 던지도록 강제합니다 — 토큰이 다른 목적으로 오용되는 걸 방지합니다.
10. Spring Security 필터와 통합
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
public class JwtAuthFilter extends OncePerRequestFilter {
private final TokenVerifier verifier;
public JwtAuthFilter(TokenVerifier verifier) {
this.verifier = verifier;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
try {
var claims = verifier.verify(header.substring(7));
var auth = new UsernamePasswordAuthenticationToken(
claims.getSubject(), null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (InvalidTokenException e) {
SecurityContextHolder.clearContext();
}
}
chain.doFilter(request, response);
}
}
Authorization: Bearer <token>헤더에서 토큰을 추출합니다.- 섹션 4.3에서 작성한
TokenVerifier로 검증합니다. - 토큰이 잘못되면 context를 clear합니다 — 다음 Spring Security authorization 단계에서 요청이 차단될 것이고, filter에서 직접 exception을 던지지는 않습니다.
참고: 공개 SPA 프론트엔드의 경우 JWT를
localStorage에 저장하면 안 됩니다 — XSS 공격으로 훔쳐질 수 있습니다. 가능하면HttpOnly,Secure,SameSite=Strict쿠키에 저장하세요.
11. 토큰 검증 동작 테스트
import io.jsonwebtoken.security.Keys;
import org.junit.jupiter.api.Test;
import javax.crypto.SecretKey;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
class TokenVerifierTest {
private final SecretKey key = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
private final TokenService tokenService = new TokenService(key);
private final TokenVerifier verifier = new TokenVerifier(key);
@Test
void shouldVerifyValidToken() {
String token = tokenService.generateToken("alice");
var claims = verifier.verify(token);
assertThat(claims.getSubject()).isEqualTo("alice");
}
@Test
void shouldRejectTokenSignedWithDifferentKey() {
SecretKey otherKey = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
TokenService fakeService = new TokenService(otherKey);
String forgedToken = fakeService.generateToken("attacker");
assertThatThrownBy(() -> verifier.verify(forgedToken))
.isInstanceOf(InvalidTokenException.class);
}
}
두 번째 테스트는 공격자가 다른 key로 토큰에 서명하는 상황을 시뮬레이션합니다 — verifier는 반드시 거부해야 하고, 몰래 받아들이면 안 됩니다.
shouldVerifyValidToken() PASS
shouldRejectTokenSignedWithDifferentKey() PASS
12. 배포 전 빠른 체크리스트
- Secret이 ≥ 256비트이고, secret manager에 저장되고, 하드코딩되지 않았습니다.
- 허용할 알고리즘을 화이트리스트로 제한했고, 토큰의
alg를 믿지 않습니다. - Access token은 짧게(≤ 15분), refresh token으로 revoke 가능하게 했습니다.
- 민감한 데이터를 payload에 넣지 않았습니다.
- 필요하면
exp,iss,aud를 검증합니다. - SPA 프론트엔드에서는 JWT를
localStorage대신HttpOnly쿠키에 저장합니다. - Refresh token을 revoke/blacklist하는 메커니즘이 있습니다.
13. 결론
JWT 자체는 결함이 없습니다 — 대부분의 문제는 구현에서 나옵니다: 약한 secret, 클라이언트의 alg를 믿기, 토큰을 너무 오래 유지하기, 또는 민감한 데이터를 공개 payload에 넣기. 섹션 12의 체크리스트를 지키고 검증된 JWT 라이브러리(jjwt, nimbus-jose-jwt)를 사용하며 parser를 직접 작성하지 않으면, 흔한 대부분의 보안 구멍을 피할 수 있습니다. 다음 단계: 여러 서비스에서 토큰의 생명 주기를 더 잘 관리하려면 OAuth 2.0 / OpenID Connect를 공부해 보세요.