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를 공부해 보세요.

jwtweb-securityspring-bootauthenticationjava