이 글은 Spring Security와 JWT를 활용한 인증 시스템 구현 과정을 다룹니다. 세션 방식과의 차이점부터 시작해 Filter Chain의 동작 원리, Access Token과 Refresh Token의 분리, 그리고 쿠키 기반 Refresh Token Rotation까지 실무에 필요한 핵심 개념과 구현 방법을 정리했습니다.
이번 실습에서 구현할 인증 시스템의 요구사항입니다.
엔드포인트 접근 제어는 세 가지 수준으로 나뉩니다. /public은 누구나 접근할 수 있고, /secure는 인증된 사용자만 접근 가능하며, /admin은 ROLE_ADMIN 권한을 가진 사용자만 접근할 수 있습니다.
인증 방식은 두 가지 토큰을 사용합니다. Access Token은 Authorization: Bearer ... 헤더를 통해 전달하고, Refresh Token은 HttpOnly Cookie로 관리합니다.
Refresh Token은 회전(Rotation) 방식을 적용합니다. Refresh Token을 한 번 사용하면 기존 토큰은 무효화하고 새로운 토큰을 발급합니다. Logout 시에는 Refresh Token을 무효화하고 쿠키를 삭제합니다.
전체 인증 흐름은 다음과 같습니다.
클라이언트의 요청이 컨트롤러에 도달하기 전에 JWT 필터가 먼저 Access Token을 검증합니다. 검증에 성공하면 SecurityContext에 Authentication 객체를 저장하여 이후 요청 처리 과정에서 인증 정보를 사용할 수 있도록 합니다.
/auth/refresh 엔드포인트는 쿠키의 Refresh Token을 검증하고 다음 단계를 수행합니다. 먼저 allowlist에 해당 토큰이 존재하는지 확인합니다. 존재하면 기존 Refresh Token을 무효화하고 새로운 Refresh Token을 발급하여 쿠키를 갱신합니다. 마지막으로 새로운 Access Token을 JSON 형태로 반환합니다.
Session 방식에서는 서버가 인증 상태를 직접 저장하고, 클라이언트는 매 요청마다 sessionId만 전송합니다. 서버는 이 ID로 저장된 세션 정보를 조회하여 인증을 확인합니다.
JWT 방식에서는 서버가 인증 상태를 저장하지 않습니다. 클라이언트는 매 요청마다 토큰 자체를 전송하고, 서버는 토큰을 검증하여 인증 정보를 복원합니다. 서버는 토큰의 서명과 만료 시간을 확인하여 토큰의 유효성을 판단합니다.
토큰이 없는 상태에서 /secure에 접근하면 필터가 Authentication을 생성할 수 없어 401 응답을 받습니다. 반대로 유효한 토큰이 있으면 필터가 SecurityContext에 인증 정보를 저장하여 /secure에 정상적으로 접근할 수 있습니다.
JWT는 Header, Payload, Signature 세 부분으로 구성됩니다.
Payload는 Base64Url로 인코딩되어 있어 누구나 디코딩할 수 있습니다. 따라서 민감한 정보를 포함해서는 안 됩니다. Payload에는 사용자 ID, 권한, 만료 시간 등의 클레임만 포함합니다.
Signature는 Header와 Payload를 비밀키로 서명한 값입니다. 누군가 Payload를 변조하면 Signature 검증이 실패하여 무결성을 보장할 수 있습니다.
Access Token이 만료된 후 요청을 보내면 401 응답과 함께 JwtException 로그가 출력됩니다. 이를 통해 서버가 매 요청마다 토큰을 검증한다는 것을 확인할 수 있습니다.
요청은 다음 순서로 처리됩니다.
Client → Security Filter Chain → DispatcherServlet → Controller
JWT 인증은 Controller에 도달하기 전에 필터에서 처리되어야 합니다. 필터가 토큰을 검증하고 SecurityContext에 인증 정보를 저장하면, 이후 컨트롤러와 서비스에서 인증 정보를 사용할 수 있습니다.
인증 결과는 SecurityContext에 저장됩니다. SecurityContextHolder는 ThreadLocal 기반으로 동작하여 각 스레드마다 독립적인 인증 정보를 유지합니다.
ThreadLocal은 전역 변수와 달리 스레드 간 격리를 보장하여 동시성 문제를 방지합니다. 다만 스레드 풀을 사용하는 환경에서는 스레드 재사용 시 이전 요청의 정보가 남아있을 수 있으므로 요청 처리 후 반드시 clear 해야 합니다.
컨트롤러에서 @AuthenticationPrincipal을 사용하면 현재 인증된 사용자의 principal을 직접 주입받을 수 있습니다.
이 어노테이션의 원천은 다음 코드입니다.
SecurityContextHolder.getContext().getAuthentication().getPrincipal()
이번 실습에서는 principal에 userId(String)를 저장하여 사용했습니다. 실무에서는 커스텀 객체를 principal로 사용하여 더 많은 정보를 전달할 수 있습니다.
SecurityConfig는 어떤 엔드포인트를 누가 접근할 수 있는지 정의하고, 인증/인가 실패 시 응답을 설정합니다.
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public").permitAll()
.requestMatchers(HttpMethod.GET, "/auth/login").permitAll()
.requestMatchers(HttpMethod.POST, "/auth/refresh", "/auth/logout").permitAll()
.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> {
res.setStatus(401);
res.setContentType(MediaType.TEXT_PLAIN_VALUE);
res.setCharacterEncoding(StandardCharsets.UTF_8.name());
res.getWriter().write("UNAUTHORIZED");
})
.accessDeniedHandler((req, res, e) -> {
res.setStatus(403);
res.setContentType(MediaType.TEXT_PLAIN_VALUE);
res.setCharacterEncoding(StandardCharsets.UTF_8.name());
res.getWriter().write("FORBIDDEN");
})
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
로그인은 누구나 접근할 수 있도록 permitAll()을 적용합니다. Refresh와 Logout 엔드포인트도 permitAll()로 열어두지만 내부에서 Refresh Token 검증이 실패하면 401로 처리합니다. 이는 Access Token이 만료된 상태에서도 재발급을 받을 수 있도록 하기 위함입니다.
/admin은 hasRole("ADMIN")으로 권한을 검사합니다. 인증은 되었지만 권한이 없으면 403 응답을 받습니다.
exceptionHandling()에서 401과 403 응답을 명확하게 정의합니다. 디버깅과 재현성을 위해 sendError() 대신 직접 status와 body를 설정합니다.
addFilterBefore()로 JWT 인증 필터를 UsernamePasswordAuthenticationFilter 앞에 배치하여 컨트롤러 전에 토큰 검증이 이루어지도록 합니다.
doFilterInternal() 메서드가 JWT 인증의 핵심입니다. 이 메서드에서 SecurityContextHolder.getContext().setAuthentication(authentication)를 호출하는 한 줄이 /secure가 통과되는 이유를 결정합니다.
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
try {
Claims claims = jwtProvider.parseAndValidate(token);
jwtProvider.assertTokenType(claims, "access");
String userId = claims.getSubject();
List<String> roles = (List<String>) claims.get("roles", List.class);
var authorities = roles.stream()
.map(r -> r.startsWith("ROLE_") ? r : "ROLE_" + r)
.map(SimpleGrantedAuthority::new)
.toList();
Authentication authentication =
new UsernamePasswordAuthenticationToken(userId, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (JwtException | IllegalArgumentException e) {
log.warn("[JwtAuthFilter] token invalid: {} - {}", e.getClass().getSimpleName(), e.getMessage());
}
filterChain.doFilter(request, response);
}
헤더가 없거나 Bearer로 시작하지 않으면 필터를 그냥 통과시킵니다. Public 엔드포인트도 이 필터를 거치므로 토큰이 없어도 anonymous 상태로 진행할 수 있어야 합니다.
parseAndValidate()로 서명과 만료 시간을 검증합니다. 실패하면 JwtException이 발생합니다.
typ=access를 확인하여 Refresh Token을 Access Token으로 사용하는 실수를 방지합니다.
sub에서 userId를 추출하고 roles를 GrantedAuthority로 변환합니다. 권한 문자열이 ROLE_로 시작하지 않으면 접두사를 붙여줍니다.
UsernamePasswordAuthenticationToken으로 Authentication 객체를 생성하고 SecurityContext에 저장합니다. 이 시점부터 이 요청은 인증된 사용자로 인식됩니다.
예외가 발생하면 로그를 남기고 anonymous 상태로 필터 체인을 진행합니다. SecurityConfig에서 정의한 규칙에 따라 401이나 403 응답을 받게 됩니다.
JwtProvider는 토큰 생성과 검증을 담당합니다. Access Token과 Refresh Token을 typ 클레임으로 구분하고, Refresh Token에는 jti를 부여하여 고유성을 보장합니다.
public String createAccessToken(String userId, List<String> roles) {
return createToken("access", userId, roles, accessExpMinutes);
}
public String createRefreshToken(String userId, List<String> roles) {
return createToken("refresh", userId, roles, refreshExpMinutes);
}
private String createToken(String type, String userId, List<String> roles, long expMinutes) {
Instant now = Instant.now();
Instant exp = now.plusSeconds(expMinutes * 60);
JwtBuilder builder = Jwts.builder()
.subject(userId)
.claim("roles", roles)
.claim("typ", type)
.issuedAt(Date.from(now))
.expiration(Date.from(exp));
if ("refresh".equals(type)) {
builder.id(UUID.randomUUID().toString());
}
return builder.signWith(key, Jwts.SIG.HS256).compact();
}
public Claims parseAndValidate(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
public void assertTokenType(Claims claims, String expectedType) {
String typ = claims.get("typ", String.class);
if (!expectedType.equals(typ)) {
throw new IllegalArgumentException("Invalid token type. expected=" + expectedType + ", actual=" + typ);
}
}
sub에는 userId를, roles에는 권한 목록을 저장합니다. typ으로 Access와 Refresh를 구분합니다.
Refresh Token은 회전 시마다 항상 새로운 토큰이 되도록 jti에 UUID를 부여합니다. 이는 표준 클레임입니다.
parseAndValidate()에서 서명 검증과 만료 검증을 수행합니다. 실패하면 JwtException이 발생하여 토큰이 유효하지 않음을 알립니다.
assertTokenType()으로 토큰 용도를 강제합니다. Access Token을 요구하는 곳에 Refresh Token을 사용하거나 그 반대의 경우를 막을 수 있습니다.
/auth/refresh를 permitAll로 열어둔 이유는 이미 만료된 Access Token이 없어도 재발급을 받아야 하기 때문입니다. 대신 Refresh Token 쿠키 검증에 실패하면 401로 차단합니다.
@PostMapping("/refresh")
public ResponseEntity<Map<String, String>> refresh(
@CookieValue(name = "refreshToken", required = false) String refreshToken,
HttpServletResponse response
) {
if (refreshToken == null || refreshToken.isBlank()) {
return ResponseEntity.status(401).body(Map.of("error", "missing refresh cookie"));
}
Claims claims = jwtProvider.parseAndValidate(refreshToken);
jwtProvider.assertTokenType(claims, "refresh");
if (!refreshTokenStore.exists(refreshToken)) {
return ResponseEntity.status(401).body(Map.of("error", "refresh revoked/unknown"));
}
String userId = claims.getSubject();
List<String> roles = (List<String>) claims.get("roles", List.class);
refreshTokenStore.revoke(refreshToken);
String newRefreshToken = jwtProvider.createRefreshToken(userId, roles);
refreshTokenStore.save(newRefreshToken, userId);
String newAccessToken = jwtProvider.createAccessToken(userId, roles);
ResponseCookie cookie = ResponseCookie.from("refreshToken", newRefreshToken)
.httpOnly(true)
.secure(false)
.sameSite("Lax")
.path("/auth")
.maxAge(Duration.ofMinutes(60))
.build();
response.addHeader("Set-Cookie", cookie.toString());
return ResponseEntity.ok(Map.of("accessToken", newAccessToken));
}
쿠키가 없으면 401을 반환합니다.
Refresh Token의 서명과 만료를 검증하고 typ=refresh를 확인합니다.
Allowlist에 토큰이 없으면 이미 무효화되었거나 알 수 없는 토큰이므로 401을 반환합니다.
Rotate의 핵심은 기존 Refresh Token을 무효화하는 것입니다. revoke()로 allowlist에서 제거합니다.
새 Refresh Token을 생성하고 allowlist에 저장합니다. 새 Access Token도 함께 생성합니다.
Refresh Token은 HttpOnly 쿠키로 갱신하고, path=/auth로 제한하여 보안을 강화합니다. 로컬 환경에서는 secure=false를 사용하지만 운영 환경에서는 반드시 secure=true로 설정해야 합니다.
Access Token은 JSON 응답 바디로 반환하여 클라이언트가 Authorization 헤더로 사용할 수 있도록 합니다.
@PostMapping("/logout")
public ResponseEntity<Map<String, String>> logout(
@CookieValue(name = "refreshToken", required = false) String refreshToken,
HttpServletResponse response
) {
if (refreshToken != null && !refreshToken.isBlank()) {
refreshTokenStore.revoke(refreshToken);
}
ResponseCookie deleteCookie = ResponseCookie.from("refreshToken", "")
.httpOnly(true)
.secure(false)
.sameSite("Lax")
.path("/auth")
.maxAge(Duration.ZERO)
.build();
response.addHeader("Set-Cookie", deleteCookie.toString());
return ResponseEntity.ok(Map.of("message", "logged out"));
}
서버 저장소에서 Refresh Token을 무효화합니다. 쿠키가 없으면 이 단계는 생략됩니다.
쿠키를 삭제하려면 maxAge(Duration.ZERO)를 설정합니다. 중요한 점은 path가 발급 때와 동일해야 제대로 삭제된다는 것입니다. 여기서는 /auth로 일치시킵니다.
curl -i http://localhost:8080/secure
토큰 없이 요청하면 401 응답을 받습니다.
curl -s "http://localhost:8080/auth/login?user=user"
로그인하여 Access Token을 받습니다.
curl -i -H "Authorization: Bearer <accessToken>" http://localhost:8080/secure
받은 토큰으로 요청하면 200 응답을 받습니다.
curl -i -H "Authorization: Bearer <ROLE_USER accessToken>" http://localhost:8080/admin
ROLE_USER 권한만 있는 토큰으로 /admin에 접근하면 403 응답을 받습니다.
curl -i -H "Authorization: Bearer <expired accessToken>" http://localhost:8080/secure
access-token-exp-minutes=1로 설정하고 1분이 지난 후 요청하면 401 응답과 함께 필터에서 JwtException 경고 로그가 출력됩니다.
curl -i -c cookies.txt "http://localhost:8080/auth/login?user=user"
로그인하면서 Refresh Token을 쿠키 파일에 저장하고 Access Token을 받습니다.
curl -i -b cookies.txt -c cookies.txt -X POST http://localhost:8080/auth/refresh
Refresh를 호출하면 쿠키의 Refresh Token이 갱신되고 새로운 Access Token이 발급됩니다.
curl -i -b cookies.txt -c cookies.txt -X POST http://localhost:8080/auth/logout
Logout을 호출하면 Refresh Token이 무효화되고 쿠키가 삭제됩니다.
curl -i -b cookies.txt -c cookies.txt -X POST http://localhost:8080/auth/refresh
Logout 후 Refresh를 시도하면 401 응답을 받습니다.
401 (UNAUTHORIZED)은 인증(Authentication)이 없거나 유효하지 않을 때 발생합니다. 토큰이 없거나, 만료되었거나, 서명 오류가 있거나, Refresh Token이 무효화되었을 때 401을 반환합니다.
403 (FORBIDDEN)은 인증은 성공했지만 권한(Authority)이 부족할 때 발생합니다. /admin에 ROLE_ADMIN이 없는 사용자가 접근하면 403을 반환합니다.
iat(issuedAt)가 초 단위이고 동일한 sub, roles, typ, exp 조건이면 토큰이 같아 보일 수 있습니다.
Refresh Token에 jti(UUID)를 추가하여 발급마다 토큰이 달라지도록 보장했습니다. jti는 JWT의 표준 클레임으로 토큰의 고유 식별자 역할을 합니다.
쿠키를 삭제할 때는 생성할 때와 동일한 path를 지정해야 합니다. 이 실습에서는 /auth로 통일했습니다.
운영 환경에서는 Secure=true를 설정하여 HTTPS에서만 쿠키를 전송하도록 해야 합니다. 도메인과 서브도메인 설정도 생성과 삭제 시 일치해야 합니다.
현재 Refresh 엔드포인트는 parseAndValidate()에서 예외를 던질 수 있습니다. 만료나 서명 오류 시 JwtException이 발생합니다.
실무에서는 try-catch로 감싸 401을 안정적으로 반환하는 것이 좋습니다.
try {
Claims claims = jwtProvider.parseAndValidate(refreshToken);
jwtProvider.assertTokenType(claims, "refresh");
} catch (JwtException | IllegalArgumentException e) {
return ResponseEntity.status(401).body(Map.of("error", "invalid refresh token"));
}
JWT 방식에서는 서버가 매 요청마다 토큰을 검증하고 Authentication을 생성하여 SecurityContext에 저장합니다. 세션과 달리 서버에 상태를 저장하지 않고 토큰 자체에 포함된 정보로 인증을 복원합니다.
Filter Chain은 Controller 이전에 인증과 인가를 완료하기 위한 파이프라인입니다. 요청이 비즈니스 로직에 도달하기 전에 JWT 검증, 인증 정보 저장, 권한 확인 등을 수행하여 보안을 보장합니다.
/secure는 authenticated 여부로 결정되어 인증이 없으면 401을 반환합니다./admin은 authority(권한)로 결정되어 인증은 있지만 권한이 없으면 403을 반환합니다.
HttpOnly Cookie는 JavaScript로 접근할 수 없어 XSS 공격으로부터 안전합니다. Refresh Token은 만료 시간이 길어 탈취 시 피해가 크므로 쿠키로 관리하고, Access Token은 짧은 만료 시간과 함께 헤더로 전송하여 유연성과 보안을 모두 확보합니다.
Refresh Token을 사용할 때마다 기존 토큰을 무효화(revoke)하고 새 토큰을 발급합니다. Allowlist에서 기존 토큰을 제거하고 새 토큰을 저장하며,
jti로 각 토큰의 고유성을 보장합니다. 이를 통해 한 번 사용된 Refresh Token을 재사용할 수 없게 만들어 보안을 강화합니다.
JWT 방식에서 서버는 매 요청마다 토큰 검증, Authentication 생성, SecurityContext 저장 과정을 통해 인증을 복원합니다.
Filter Chain은 Controller 이전에 인증과 인가가 완료되도록 하는 파이프라인입니다. JWT 필터를 적절한 위치에 배치하여 모든 요청을 검증할 수 있습니다.
/secure는 authenticated 여부로 판단하여 401을 반환하고, /admin은 authority로 판단하여 403을 반환합니다.
Refresh Token은 HttpOnly Cookie 기반으로 관리하고 Rotate와 Revoke를 적용하여 실무 보안 수준에 가깝게 설계할 수 있습니다.
디버깅과 실무 적용을 위해서는 401과 403을 원인으로 명확히 구분하고, Refresh 예외를 안정적으로 처리하며, 쿠키의 path와 secure 설정을 정합성 있게 유지하고, Refresh Token의 고유성을 jti로 보장하는 것이 중요합니다.