Backend
Last updated on

[Spring Boot] Spring Security + JWT 인증 구현하기


세션 대신 JWT로 인증을 처리하도록 구현한 내용을 정리한다.

작성 당시 환경은 다음과 같다. 최신 버전에서 달라진 부분은 글 마지막에 따로 정리했다.

항목 버전
Spring Boot 2.7.x
Spring Security 5.7.x
jjwt 0.11.5

JWT란

JWT(JSON Web Token)는 인증에 필요한 정보를 JSON으로 담고, 서명을 붙여 위변조를 막은 토큰이다. .으로 구분된 세 부분으로 이루어진다.

xxxxx.yyyyy.zzzzz
Header.Payload.Signature
구성 내용 예시 (디코딩한 값)
Header 서명 알고리즘 {"alg":"HS512"}
Payload 토큰에 담는 정보(claim) {"sub":"user1","iat":1668124800,"exp":1668128400}
Signature Header + Payload를 비밀키로 서명한 값 (바이너리)

Payload에 자주 쓰는 claim은 다음과 같다.

  • sub (subject) — 토큰의 주인. 보통 사용자 ID를 넣는다.
  • iat (issued at) — 발급 시각
  • exp (expiration) — 만료 시각

Header와 Payload는 Base64Url로 인코딩만 된 것이지 암호화된 것이 아니다. jwt.io에 토큰을 붙여넣으면 누구나 내용을 볼 수 있다. 그래서 비밀번호 같은 민감한 정보는 Payload에 넣으면 안 된다.

대신 Signature 덕분에 내용을 바꿀 수는 없다. 누군가 Payload의 sub를 다른 사용자로 고치면, 서버가 비밀키로 다시 계산한 서명과 일치하지 않아 검증에 실패한다.


세션 방식과의 차이

세션 JWT
인증 정보 저장 위치 서버 (세션 저장소) 클라이언트 (토큰)
서버가 하는 일 세션 ID로 저장소 조회 토큰 서명 검증
서버 확장 시 세션 공유 필요 (Redis 등) 비밀키만 같으면 됨
강제 로그아웃 세션 삭제로 즉시 가능 만료 전까지 어려움

JWT는 서버가 상태를 저장하지 않는(stateless) 방식이라 서버를 여러 대로 늘리기 쉽다. 반면 한 번 발급한 토큰은 만료 전까지 막기 어렵다는 단점이 있다.


전체 인증 흐름

[로그인]
Client ── POST /api/auth/login (id, pw) ──▶ Server
Client ◀──────── { token: "eyJ..." } ────── Server   ← 사용자 확인 후 토큰 발급

[이후 요청]
Client ── GET /api/me
          Authorization: Bearer eyJ... ───▶ JwtAuthenticationFilter
                                              │ 1. 헤더에서 토큰 추출
                                              │ 2. 서명·만료 검증
                                              │ 3. SecurityContext에 인증 정보 저장
                                              ▼
                                            Controller (Principal로 사용자 조회)

이 흐름을 만들기 위해 구현할 클래스는 다음과 같다.

  1. JwtTokenProvider — 토큰 생성·검증·파싱
  2. JwtAuthenticationFilter — 요청마다 토큰을 확인하는 필터
  3. SecurityConfig — 필터를 Spring Security에 등록
  4. AuthController — 로그인 시 토큰 발급

1. 의존성 추가

jjwt는 API, 구현체, JSON 처리 모듈이 나뉘어 있어 세 개를 모두 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-security'

implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

jjwt-impl과 jjwt-jackson은 코드에서 직접 참조하지 않으므로 runtimeOnly로 충분하다. jjwt 공식 문서도 이 방식을 권장한다.


2. 설정값 추가

# application.properties
jwt.secret=<Base64로 인코딩한 64바이트 이상의 랜덤 문자열>
jwt.validSeconds=3600

HS512 알고리즘은 512비트(64바이트) 이상의 키를 요구한다. 키가 짧으면 WeakKeyException이 발생한다. 아래 명령으로 충분한 길이의 키를 만들 수 있다.

openssl rand -base64 64

비밀키가 유출되면 누구나 유효한 토큰을 만들 수 있다. 실제 서버에서는 환경 변수 등으로 주입하고 저장소에 커밋하지 않도록 한다.


3. JwtTokenProvider — 토큰 생성·검증

import java.security.Key;
import java.util.Base64;
import java.util.Date;

import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.Keys;
import lombok.extern.log4j.Log4j2;

@Log4j2
@Component
public class JwtTokenProvider implements InitializingBean {
    private final String secret;
    private final long tokenValidMillis;
    private Key key;

    public JwtTokenProvider(@Value("${jwt.secret}") String secret,
                            @Value("${jwt.validSeconds}") Long validSeconds) {
        this.secret = secret;
        this.tokenValidMillis = validSeconds * 1000;
    }

    /*
     * 빈 생성 후 secret 문자열로 서명용 Key 객체 생성
     */
    @Override
    public void afterPropertiesSet() {
        byte[] keyBytes = Base64.getDecoder().decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    /*
     * token 생성
     */
    public String generateTokenString(String id) {
        Date now = new Date();
        Date expDate = new Date(now.getTime() + tokenValidMillis);

        return Jwts.builder()
                .setSubject(id)            // sub: 사용자 ID
                .setIssuedAt(now)          // iat: 발급 시각
                .setExpiration(expDate)    // exp: 만료 시각
                .signWith(key, SignatureAlgorithm.HS512)
                // .signWith(SignatureAlgorithm.HS512, key) // Deprecated
                .compact();
    }

    /*
     * token 서명·만료 검증
     */
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            // Jwts.parser().setSigningKey(key).parseClaimsJws(token); // Deprecated
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }

    /*
     * token에서 사용자 ID를 꺼내 Authentication 객체 생성
     */
    public Authentication getAuthenticateAction(String token) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
        log.info("get authentication from token : " + claims);

        return new UsernamePasswordAuthenticationToken(claims.getSubject(), token, null);
    }
}

메서드별 역할은 다음과 같다.

  • afterPropertiesSet() — InitializingBean의 메서드로, 생성자 주입이 끝난 뒤 한 번 호출된다. Base64 문자열을 디코딩해 HMAC용 Key 객체를 만들어 둔다.
  • generateTokenString() — sub, iat, exp를 채우고 비밀키로 서명해 xxxxx.yyyyy.zzzzz 형태의 문자열을 만든다.
  • validateToken() — parseClaimsJws()가 형식·서명·만료 시간을 한 번에 검증한다. 문제가 있으면 원인별로 다른 예외가 발생하므로, 예외 종류별로 로그를 남기고 false를 반환한다. 자세한 검사 과정은 다음 단계(필터)에서 다룬다.
  • getAuthenticateAction() — 토큰의 sub(사용자 ID)를 principal로 하는 Authentication 객체를 만든다. 이 객체가 뒤에서 SecurityContext에 저장된다.

getAuthenticateAction()에서는 처음에 parse(token)을 쓰고 Claims로 형변환했었다. validateToken()에서 이미 검증한 토큰이라 동작에는 문제가 없지만, 서명된 토큰만 받는다는 의도가 드러나도록 parseClaimsJws()로 바꿨다. 형변환도 필요 없어진다.

UsernamePasswordAuthenticationToken의 세 번째 인자는 권한(authorities) 목록이다. 여기서는 권한 구분 없이 로그인 여부만 확인하므로 null을 넘겼다. 세 인자 생성자는 인증이 완료된 상태(authenticated = true)의 객체를 만든다.


4. JwtAuthenticationFilter — 요청마다 토큰 확인

import java.io.IOException;

import javax.servlet.FilterChain;
import javax.servlet.GenericFilter;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;

import lombok.extern.log4j.Log4j2;

@Log4j2
public class JwtAuthenticationFilter extends GenericFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    private final JwtTokenProvider tokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String jwt = resolveToken(httpServletRequest);
        String requestURI = httpServletRequest.getRequestURI();

        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            Authentication authentication = tokenProvider.getAuthenticateAction(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        } else {
            log.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
        }

        chain.doFilter(request, response);
    }

    /*
     * "Authorization: Bearer <token>" 헤더에서 토큰 부분만 추출
     */
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

필터에서 토큰을 확인하는 순서

doFilter()는 요청이 들어올 때마다 다음 순서로 실행된다.

  1. 토큰 추출 — resolveToken()이 Authorization 헤더를 읽는다. 값이 Bearer 로 시작하면 그 뒤의 문자열만 잘라 토큰으로 사용한다. 헤더가 없거나 형식이 다르면 null을 반환한다.
  2. 유효성 검사 — 토큰이 있으면 tokenProvider.validateToken()으로 넘긴다.
  3. 인증 정보 저장 — 검사를 통과하면 getAuthenticateAction()으로 Authentication 객체를 만들어 SecurityContextHolder에 저장한다. 통과하지 못하면 아무것도 저장하지 않는다.
  4. 다음 필터로 진행 — 결과와 상관없이 chain.doFilter()를 호출한다.

validateToken()이 검사하는 것

validateToken()은 내부적으로 parseClaimsJws(token) 한 번으로 아래 항목을 검사한다. 하나라도 실패하면 그 자리에서 예외가 발생한다.

검사 내용 실패 시 예외
빈 문자열이 아닌지 IllegalArgumentException
.으로 나뉜 세 부분이고, 각 부분이 Base64Url·JSON으로 읽히는지 MalformedJwtException
서명된 토큰(JWS)인지. 서명 없는 토큰(alg: none)은 거부 UnsupportedJwtException
Header + Payload를 서버 비밀키로 다시 서명한 값이 Signature와 같은지 SecurityException (SignatureException)
exp가 현재 시각보다 뒤인지 ExpiredJwtException

이 중 핵심은 서명 검증이다. 서버는 토큰을 따로 저장해 두지 않는다. 대신 받은 토큰의 Header와 Payload를 자기 비밀키로 다시 서명해 보고, 토큰에 붙어 온 Signature와 비교한다. 비밀키를 모르는 사람은 올바른 Signature를 만들 수 없으므로, 둘이 같다면 이 서버가 발급했고 그 뒤로 내용이 바뀌지 않은 토큰이라고 판단할 수 있다.

서명이 맞아야 그 뒤에 exp를 확인한다. 위조된 토큰의 만료 시간은 믿을 수 없기 때문이다.

상황별 결과

상황 발생 예외 로그 결과
헤더 없음 / Bearer 아님 (검사 안 함) 유효한 JWT 토큰이 없습니다 인증 없음
복사하다 .이 빠지는 등 형식이 깨짐 MalformedJwtException 잘못된 JWT 서명입니다. 인증 없음
Payload의 sub를 다른 사용자로 수정 SignatureException 잘못된 JWT 서명입니다. 인증 없음
다른 비밀키로 서명한 토큰 SignatureException 잘못된 JWT 서명입니다. 인증 없음
유효 시간이 지난 토큰 ExpiredJwtException 만료된 JWT 토큰입니다. 인증 없음
정상 토큰 - - SecurityContext에 인증 저장

“인증 없음”인 요청은 필터를 그대로 통과한다. 그 뒤 접근 규칙에서 authenticated()가 걸린 경로라면 거부되고, permitAll() 경로라면 정상 처리된다.

validateToken()이 예외를 밖으로 던지지 않고 false만 반환하는 이유도 여기에 있다. 예외가 필터 밖으로 나가면 permitAll() 경로까지 에러가 나고, 응답도 500으로 바뀐다. 다만 이렇게 하면 클라이언트는 “만료”와 “위조”를 구분할 수 없다. 만료일 때만 재발급을 요청하게 하려면, 만료 여부를 request attribute에 담아 두고 AuthenticationEntryPoint에서 응답 코드나 메시지를 다르게 내려주는 방식을 쓸 수 있다.

그 밖의 동작

이 필터는 토큰이 없거나 잘못돼도 요청을 막지 않는다. 인증 정보를 SecurityContext에 넣을지 말지만 결정하고 다음 필터로 넘긴다. 실제로 요청을 거부하는 것은 뒤에 나오는 SecurityConfig의 접근 규칙(authenticated())이다. 역할을 이렇게 나눠 두면 로그인 API처럼 토큰 없이 접근해야 하는 경로도 같은 필터를 문제없이 통과한다.

필터에 @Component를 붙이지 않은 것도 의도한 것이다. @Component로 빈 등록을 하면 Spring Boot가 서블릿 필터로 한 번 더 자동 등록해서, Security 필터 체인과 서블릿 필터 체인에서 두 번 실행된다. 여기서는 SecurityConfig에서 new로 생성해 Security 체인에만 넣는다.


5. SecurityConfig — 필터 등록

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider tokenProvider;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .cors().and()
            .csrf().disable()
            .httpBasic().disable()
            .formLogin().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeHttpRequests()
                .antMatchers("/api/auth/login").permitAll()
                .anyRequest().authenticated()
            .and()
            .addFilterBefore(new JwtAuthenticationFilter(tokenProvider),
                             UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

설정별 이유는 다음과 같다.

설정 이유
csrf().disable() CSRF는 브라우저가 쿠키를 자동으로 보내는 점을 노린 공격이다. 토큰을 Authorization 헤더로 직접 보내는 방식에서는 해당되지 않는다.
httpBasic(), formLogin() 비활성화 로그인은 직접 만든 API로 처리하므로 기본 로그인 방식은 끈다.
SessionCreationPolicy.STATELESS Spring Security가 세션을 만들지 않게 한다. 인증 상태는 매 요청의 토큰으로만 판단한다.
antMatchers(...).permitAll() 로그인 API는 토큰이 없어도 접근할 수 있어야 한다.
anyRequest().authenticated() 나머지는 SecurityContext에 인증 정보가 있어야 통과한다.
addFilterBefore(...) 기본 인증 필터보다 앞에서 JWT를 확인하도록 순서를 지정한다.

토큰 없이 보호된 API를 호출하면 기본적으로 403 Forbidden이 반환된다. 401 Unauthorized로 응답하려면 .exceptionHandling().authenticationEntryPoint(...)로 별도 처리를 등록해야 한다.


6. 로그인 시 토큰 발급

사용자 ID와 비밀번호를 확인한 뒤, ID로 토큰을 만들어 응답한다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserService userService;

    @PostMapping("/login")
    public ResponseEntity<Map<String, String>> login(@RequestBody LoginRequest request) {
        // 아이디·비밀번호 확인 (실패 시 예외)
        String id = userService.authenticate(request.getId(), request.getPassword());

        // id로 jwt token 생성
        String jwtToken = jwtTokenProvider.generateTokenString(id);

        return ResponseEntity.ok(Map.of("token", jwtToken));
    }
}

userService.authenticate()는 DB에서 사용자를 찾아 비밀번호를 비교하는 부분이다. 프로젝트마다 구현이 다르므로 생략했다.


7. 컨트롤러에서 사용자 정보 꺼내기

필터가 SecurityContext에 넣어 둔 인증 정보는 컨트롤러에서 Principal 파라미터로 받을 수 있다.

@RestController
public class UserController {

    @GetMapping("/api/me")
    public ResponseEntity<String> me(Principal principal) {
        // 토큰의 sub 값 (= 로그인한 사용자 ID)
        String id = principal.getName();
        return ResponseEntity.ok(id);
    }
}

getAuthenticateAction()에서 principal로 사용자 ID 문자열을 넣었기 때문에 principal.getName()이 그 ID를 그대로 반환한다.

같은 값을 다음 방법으로도 꺼낼 수 있다.

// 1. 어노테이션으로 principal 직접 주입
@GetMapping("/api/me")
public String me(@AuthenticationPrincipal String id) { ... }

// 2. 컨트롤러 밖(서비스 등)에서 꺼내기
String id = SecurityContextHolder.getContext().getAuthentication().getName();

동작 확인

# 1. 로그인 → 토큰 발급
curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"id":"user1","password":"1234"}'
# {"token":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOi..."}

# 2. 토큰 없이 호출 → 403
curl -i http://localhost:8080/api/me

# 3. 토큰을 넣어 호출 → 사용자 ID 반환
curl http://localhost:8080/api/me \
  -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOi..."
# user1

# 4. 서명 부분 가운데의 글자 하나를 바꿔서 호출 → 403
#    (마지막 글자는 남는 비트만 담고 있어서, 바꿔도 같은 값으로 읽힐 수 있다)
#    로그: 잘못된 JWT 서명입니다.
curl -i http://localhost:8080/api/me \
  -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOi...(변조)"

만료된 토큰은 jwt.validSeconds를 10 정도로 줄여서 발급받고, 10초 뒤에 호출해 보면 확인할 수 있다. 로그에 만료된 JWT 토큰입니다.가 찍히고 403이 반환된다.


주의할 점

  • Payload는 누구나 읽을 수 있다. 사용자 ID 정도만 넣고, 민감한 정보는 넣지 않는다.
  • 발급한 토큰은 만료 전까지 취소하기 어렵다. 그래서 유효 시간(access token)을 짧게 잡고, 재발급용 refresh token을 따로 두는 구성을 많이 쓴다.
  • 클라이언트 저장 위치에 따라 위험이 다르다. localStorage는 XSS 공격에 노출되고, 쿠키에 저장하면 다시 CSRF 대비가 필요하다.

Spring Boot 3 / Spring Security 6 / jjwt 0.12에서 달라진 점

위 코드를 최신 버전에서 쓰려면 다음 부분을 바꿔야 한다.

javax → jakarta

Spring Boot 3부터 Jakarta EE 9+를 사용하므로 패키지 이름이 바뀌었다.

// Spring Boot 2.x
import javax.servlet.FilterChain;

// Spring Boot 3.x
import jakarta.servlet.FilterChain;

Security 설정: 람다 DSL

.and()로 이어 붙이던 방식은 Spring Security 6.1에서 deprecated 됐고, antMatchers()는 6.0에서 제거되어 requestMatchers()로 바뀌었다.

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .cors(Customizer.withDefaults())
        .csrf(AbstractHttpConfigurer::disable)
        .httpBasic(AbstractHttpConfigurer::disable)
        .formLogin(AbstractHttpConfigurer::disable)
        .sessionManagement(session ->
            session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/auth/login").permitAll()
            .anyRequest().authenticated())
        .addFilterBefore(new JwtAuthenticationFilter(tokenProvider),
                         UsernamePasswordAuthenticationFilter.class);

    return http.build();
}

jjwt 0.12

빌더와 파서의 메서드 이름이 바뀌었고, 서명 알고리즘은 키 길이를 보고 자동으로 정해진다.

implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
private SecretKey key;  // Key → SecretKey (verifyWith()가 SecretKey를 받음)

// 생성
Jwts.builder()
    .subject(id)              // setSubject → subject
    .issuedAt(now)            // setIssuedAt → issuedAt
    .expiration(expDate)      // setExpiration → expiration
    .signWith(key)            // 알고리즘 생략 시 키 길이로 결정 (64바이트 → HS512)
    .compact();

// 검증·파싱
Claims claims = Jwts.parser()        // parserBuilder() → parser()
    .verifyWith(key)                 // setSigningKey → verifyWith
    .build()
    .parseSignedClaims(token)        // parseClaimsJws → parseSignedClaims
    .getPayload();                   // getBody → getPayload

참고