정보를 JSON으로 표현해 전달하는 토큰 형식. Access Token이라고도 한다.
서버가 클라이언트의 세션 정보를 저장할 필요 없이(Stateless) 인증을 수행할 수 있는 방법의 필요성에 의해 제안됨

| 클레임 | 의미 |
|---|---|
sub (subject) | 회원 ID이 주로 담김 (토큰의 주체) |
iat (issued at) | 발급 시각 |
exp (expiration) | 만료 시각 |
role | 애플리케이션이 정한 사용자 역할. 표준 등록 클레임이 아닌 사용자 정의 항목 |
| 이 외에 필요한 데이터 key | 이 외에 필요한 데이터 value 추가 가능 |
JWT는 Stateless, Session은 Stateful
세션 키: 식별용 쓰레기값, 서버에서 세션 키를 보고 세션을 불러와야 그 안에 정보가 있음
JWT: 클라이언트가 보내는 토큰 안에 정보가 담겨있음. 서버는 별도 정보 없이 사용자 식별이 가능

Signature =
{암호화 알고리즘}(Base64Url(Header)+"."+Base64Url(Payload),SecretKey)
최종 JWT는Base64Url(Header).Base64Url(Payload).Base64Url(Signature)로 만들어진다.
이건 '암호화'가 아니다! Signature는 Header와 Payload 뒤에 붙어 '이 내용을 내가 썼다'는 것을 보장해주는 수단일 뿐, 헤더와 페이로드는 Base64Url 디코딩 시 누구나 평문을 확인할 수 있다.
따라서 JWT에는 민감한 정보를 담아선 안된다.
Base64URL은 표준 Base64 인코딩 방식에서 URL, 파일 이름 등과 충돌하지 않도록
+,/,=을 처리하는 인코딩 변형이다.
어떤 문자열이든 URL에 안전하게 넣을 수 있도록 변환해주는 함수라고 생각하면 된다.
다음 의존성이 필요하다.
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-gson:0.12.6'
@Configuration
@EnableMethodSecurity
public class AuthenticationConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
복습:
@Component가 아니라@Bean을 쓰는 이유
위 코드에서passwordEncoder()메서드는 앱 시작 시 스프링이 딱 한 번 호출해서 객체를 생성하는 팩토리 역할을 하고, 그 반환값인BCryptPasswordEncoder객체가 컨테이너에PasswordEncoder형태로 보관된다.
@Bean은 그 메서드를 빈에 등록하고 사용하는 게 아니라, 메서드의 반환 객체를 빈에 등록하는 것이다. 굳이 이런 식으로 우회하는 이유는,BCryptPasswordEncoder가 스프링 라이브러리 내부 클래스라 직접@Component로 등록할 수 없기 때문이다.
@Component
public class JwtProvider {
private final SecretKey secretKey;
private final long accessTtlSeconds; // TTL(Time To Live): 토큰 만료 시간 설정
public JwtProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.access-ttl-seconds}") long accessTtlSeconds
) {
this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
this.accessTtlSeconds = accessTtlSeconds;
}
public String createAccessToken(
User user
) {
Instant now = Instant.now();
Instant expiresAt = now.plusSeconds(accessTtlSeconds);
// 회원 정보와 만료 시각을 담아 JWT 발급 (페이로드, 서명 추가)
return Jwts.builder()
.subject(user.getId().toString())
.claim("role", user.getRole().name()) // 사용자 지정 클레임
.issuedAt(Date.from(now))
.expiration(Date.from(expiresAt))
.signWith(secretKey, Jwts.SIG.HS256) // 서명
.compact();
}
// JWT 파싱 유틸리티 메서드
public AuthUser parseToken(
String token
) {
try {
// JWT 서명과 만료 검증
Jws<Claims> signedClaims = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
Claims claims = signedClaims.getPayload();
String subject = claims.getSubject();
long userId = Long.parseLong(subject);
Role role = Role.valueOf(claims.get("role", String.class));
// 검증한 회원 정보로 인증 DTO 객체 생성
return new AuthUser(userId, role);
} catch (JwtException | IllegalArgumentException | NullPointerException exception) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
}
}
@Service
@RequiredArgsConstructor
public class CredentialService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Transactional(readOnly = true)
public User authenticate(
LoginRequest request
) {
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
String password = request.getPassword(); // 요청 비밀번호(평문)
String encodedPassword = user.getPassword(); // DB 저장된 비밀번호(비문)
if (!passwordEncoder.matches(password, encodedPassword)) { // 평문 비문 비교 (일치 확인)
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
return user;
}
}
@Service
@RequiredArgsConstructor
public class JwtAuthService {
private final CredentialService credentialService;
private final JwtProvider jwtProvider;
@Transactional(readOnly = true)
public AccessTokenResponse login(
LoginRequest request
) {
User user = credentialService.authenticate(request);
String accessToken = jwtProvider.createAccessToken(user); // JWT 발급
return new AccessTokenResponse(accessToken);
}
}
스프링 보안 프레임워크
스프링 기반 애플리케이션에 인증, 인가 기능 제공
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authorization == null || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
filterChain.doFilter(request, response);
return;
}
AuthUser authUser;
try {
String token = authorization.substring(7);
authUser = jwtProvider.parseToken(token);
} catch (ResponseStatusException exception) {
SecurityContextHolder.clearContext();
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
SimpleGrantedAuthority authority = new SimpleGrantedAuthority("ROLE_" + authUser.getRole().name());
List<SimpleGrantedAuthority> authorities = List.of(authority);
Authentication authentication = new JwtAuthenticationToken(authUser, authorities);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
try {
filterChain.doFilter(request, response);
} finally {
SecurityContextHolder.clearContext();
}
}
}
Authorization) 파싱Bearer 접두사를 포함하는 토큰 검사jwtProvider.parseToken(): JWT 토큰 만료 여부, 서명 검증401 Unauthorized 반환AuthUser 객체에 매핑"ROLE_USER", "ROLE_ADMIN" 등) 부여한 JwtAuthenticationToken(Authentication 객체) 생성SecurityContext에 인증 객체를 등록해야 인증이 필요한 보안 영역(API, Controller 등)에 접근 가능SecurityContextHolder에 등록SecurityContextHolder 정리@Configuration
public class JwtSecurityConfig {
@Bean
public SecurityFilterChain jwtSecurityFilterChain(
HttpSecurity http,
JwtProvider jwtProvider
) throws Exception {
http
// 세션 방식에 관련된 요소들을 전부 비활성화
.csrf(AbstractHttpConfigurer::disable) // 쿠키 인증 없이 Bearer 헤더를 쓰므로 CSRF 보호 끄기
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 요청마다 JWT를 검증하므로 인증용 세션 사용 안 함
.requestCache(AbstractHttpConfigurer::disable) // 로그인 후 이동할 페이지가 없어 요청 저장 끄기
.formLogin(AbstractHttpConfigurer::disable) // JSON 로그인 API를 쓰므로 기본 로그인 폼 끄기
.httpBasic(AbstractHttpConfigurer::disable) // JWT로 요청을 인증하므로 HTTP Basic 인증 끄기
.logout(AbstractHttpConfigurer::disable) // JWT 인증을 사용하므로 세션 기반 로그아웃 끄기
// 인가 설정
// 스프링 시큐리티가 AuthorizationFilter를 생성하여 필터 체인 맨 끝에 등록
.authorizeHttpRequests(authorize -> authorize
.dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
.requestMatchers(HttpMethod.GET, "/posts").permitAll()
.requestMatchers(HttpMethod.POST, "/auth/signup", "/auth/login", "/admin/signup").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
// 예외 처리
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint((request, response, exception) ->
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED))
.accessDeniedHandler((request, response, exception) ->
response.setStatus(HttpServletResponse.SC_FORBIDDEN)))
// 커스텀 필터 배치
// 스프링 시큐리티 기본 인가 필터인 AuthorizationFilter 바로 앞에 배치
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider), AuthorizationFilter.class);
return http.build();
}
}
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("/me")
public ResponseEntity<UserResponse> me(
@AuthenticationPrincipal AuthUser authUser // 필터에서 만들어진 인증 객체에 들어있는 DTO를 꺼내서 매핑, 즉 인증된 유저의 요청만 받음
) {
return ResponseEntity.ok(userService.findMe(user.getUserId()));
}
}
위의 시큐리티 설정 코드를 보면 다음과 같은 라인이 있다.
.requestMatchers("/admin/**").hasRole("ADMIN")
이는 /admin으로 시작하는 모든 API는 ROLE_ADMIN 역할이 있어야만 접근할 수 있도록 하겠다는 의미이다. 몇 가지 자주 쓰이는 검사 표현이 있다.
| 검사 표현 | 비교하는 문자열 |
|---|---|
hasRole("ADMIN") | ROLE_ADMIN |
hasAuthority("ROLE_ADMIN") | ROLE_ADMIN |
hasRole("USER") | ROLE_USER |
역할의 접두사에 ROLE_을 요구하는 것은 Spring Security만의 고유 규칙이다.
hasRole은 접두사가 있는 것으로 간주하고, hasAuthority는 조금 더 일반적으로 문자열을 그대로 비교한다.
@EnableMethodSecurity가 필요 (위쪽의 AuthenticationConfig 참고)@PreAuthorize를 이용해 권한 검사 가능@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/users")
public ResponseEntity<List<UserResponse>> users() {
return ResponseEntity.ok(userService.findAll());
}
요청 본문(@RequestBody)이나 URL 경로(@PathVariable)를 통해 사용자의 Id를 받을 경우, 클라이언트가 다른 회원의 Id를 넣어 그 사람 이름으로 글 작성, 삭제 등의 요청을 넣을 수 있다.
JWT 안에 해당 정보를 넣으면 위조가 불가능하다. (본인의 비밀 키로 서명했기 때문에)
컨트롤러 예제
@RestController
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
@PostMapping("/posts")
public ResponseEntity<PostResponse> create(
@AuthenticationPrincipal AuthUser authUser
) {
PostResponse post = postService.create(authUser.getUserId());
return ResponseEntity.status(HttpStatus.CREATED).body(post);
}
@GetMapping("/posts")
public ResponseEntity<List<PostResponse>> posts() {
return ResponseEntity.ok(postService.findAll());
}
@DeleteMapping("/posts/{id}")
public ResponseEntity<Void> delete(
@PathVariable long id,
@AuthenticationPrincipal AuthUser authUser
) {
postService.delete(id, authUser.getUserId());
return ResponseEntity.noContent().build();
}
}
JWT로부터 추출한 AuthUser 객체로부터 id를 꺼내 서비스에 넘겨준다.
서비스 예제
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final UserRepository userRepository;
@Transactional
public PostResponse create(
long authenticatedUserId
) {
User user = userRepository.findById(authenticatedUserId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
Post post = new Post(user);
Post savedPost = postRepository.save(post);
return new PostResponse(savedPost.getId(), savedPost.getUser().getId());
}
@Transactional(readOnly = true)
public List<PostResponse> findAll() {
List<Post> posts = postRepository.findAll();
return posts.stream()
.map(post -> new PostResponse(post.getId(), post.getUser().getId()))
.toList();
}
@Transactional
public void delete(
long postId,
long authenticatedUserId
) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (post.getUser().getId() != authenticatedUserId) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
postRepository.delete(post);
}
}
가입

로그인

JWT 디코딩 해보기

헤더와 페이로드도 잘 복원되고, 우측 하단의 인증 역시 프로젝트의 환경 변수 시크릿 값을 넣으니 정상적으로 확인되었다.
내 정보 조회
| JWT를 올바르게 포함한 경우 | JWT가 누락된 경우 |
|---|---|
![]() | ![]() |
관리자 API 호출
| 관리자 가입 | 관리자 로그인 |
|---|---|
![]() | ![]() |
ADMIN 역할이 없을 때 | ADMIN 역할이 있을 때 |
|---|---|
![]() | ![]() |