이 글은 Spring Security + JWT 기반 인증 구조를 Keycloak/OIDC 기반 구조로 전환하며 겪은 과정을 정리한 시리즈입니다.
현재 글: 4편 - JWT에는 ADMIN이 있는데 hasRole(“ADMIN”)이 실패한 이유
시리즈 순서
1. Spring Security + JWT에서 Keycloak/OIDC 구조로 전환한 이유
2. Direct Access Grants에서 Authorization Code + PKCE로 전환하기
3. issuer-uri 한 줄로 Keycloak JWT가 검증되는 과정
4. JWT에는 ADMIN이 있는데 hasRole(“ADMIN”)이 실패한 이유
5. 로그아웃에서만 500 에러가 발생한 이유: Caffeine Cache TTL 불일치
1. 문제 상황
관리자 전용 API에 아래와 같이 권한 제한을 걸어 두었다.
/** 관리자만 가능한 곳! */
.requestMatchers("/api/admin/**").hasRole("ADMIN")
- 사용자의 권한이 “ADMIN” 이라면, 해당 API 에 접근 가능하게끔 의도

- 하지만, 관리자 권한이 담긴 토큰을 통해 접근이 실패
2. 원인 추적 과정
1. 관리자 토큰이 잘못 발급된 것이 아닌지 확인

가장 먼저 토큰 자체를 확인했다.
JWT를 복호화해서 payload를 확인해 본 결과, 토큰 내부에는 정상적으로 관리자 권한 정보가 들어 있었다.
2. 토큰으로 Authentication을 만드는 과정 검토
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
if (claims.get("role") == null) {
throw new ApiException(HttpStatus.UNAUTHORIZED,"401","UNAUTHORIZED","권한 정보가 없는 토큰입니다.");
}
Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get("role").toString().split(","))
.map(SimpleGrantedAuthority::new)
.toList();
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, null, authorities);
}
코드의 흐름은 이러하다.
- 토큰에서 Claims를 꺼냄
- Claims에서 role 값을 가져옴
- 이를 GrantedAuthority 리스트로 변환
- UsernamePasswordAuthenticationToken을 생성

테스트 코드로 확인해 본 결과, 생성된 Authentication 객체 안에는 실제로 ADMIN 권한이 들어 있었다.
즉,
- 토큰 → Claims 변환 정상
- Claims → Authentication 변환 정상
여기까지는 문제가 없었다.
3. 그렇다면 Security Context 가 문제가 있는지 확인
다음에는 생성된 인증 정보를 SecurityContext에 저장하는 과정이 문제인지 확인했다.
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);

테스트 코드로 SecurityContext에서 인증 정보를 꺼내 확인해 보니, 여기에도 ADMIN 권한이 정상적으로 들어 있었다.
즉,
- Authentication 생성 정상
- SecurityContext 저장 정상
이 단계까지도 문제는 없었다.
3. 실제 원인
1. hasRole() 을 검색

“ROLE_ “ 이라는 prefix 가 자동으로 추가된다는 것을 발견했다.
2. hasRole() 구현체 추적
public AuthorizationManagerRequestMatcherRegistry hasRole(String role) {
return access(withRoleHierarchy(AuthorityAuthorizationManager
.hasAnyRole(AuthorizeHttpRequestsConfigurer.this.rolePrefix, new String[] { role })));
}
private String rolePrefix = "ROLE_";
코드를 추적해 본 결과 hasRole("ADMIN")은 내부적으로 단순히 "ADMIN"을 찾는 것이 아니었다.
Spring Security 내부에서는 rolePrefix = "ROLE_"가 기본값으로 사용되고 있었다.
즉, 내가 JWT에서 꺼내 Authentication 에 넣은 권한은 ADMIN 이다.
하지만, 시큐리티가 검사하는 권하는 즉, ROLE_ADMIN 이다.
4. 해결 방법
기존 코드는 Claims에서 꺼낸 권한 문자열을 그대로 SimpleGrantedAuthority에 넣고 있었다.
Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get("role").toString().split(","))
.map(SimpleGrantedAuthority::new)
.toList();
- 즉, "ADMIN"이 그대로 권한 객체
이를 아래와 같이 수정했다.
Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get("role").toString().split(","))
.map( s -> new SimpleGrantedAuthority("ROLE_"+s))
.toList();
이제 "ADMIN"은 "ROLE_ADMIN"으로 변환되어 저장되고, Spring Security의 hasRole("ADMIN")이 내부적으로 찾는 값과 정확히 일치하게 되었다.
5. 결과

관리자 전용 API 호출이 정상적으로 성공했다.
6. 정리
이번 문제의 핵심은 토큰이 잘못된 것이 아니라, Spring Security의 권한 비교 방식과 내가 저장한 권한 문자열 형식이 달랐다는 점이다.
정리하면 다음과 같다.
- JWT 내부 권한 값: ADMIN
- Authentication에 저장된 권한 값: ADMIN
- hasRole("ADMIN")이 내부적으로 찾는 값: ROLE_ADMIN
즉 문자열 하나 차이였지만, 이 차이 때문에 인증은 되었어도 인가에서 계속 실패하고 있었던 것이다.
7. 배운점
처음에는 토큰 발급이나 SecurityContext 저장 과정이 잘못된 줄 알았다.
하지만 실제 문제는 Spring Security가 hasRole()을 처리하는 내부 규칙을 정확히 이해하지 못했던 데 있었다.
이번 일을 통해 느낀 점은 다음과 같다.
- Security는 단순히 예제 코드를 따라 치는 것만으로는 부족하다
- 인증과 인가 흐름을 단계별로 직접 확인해 보는 과정이 중요하다
- 특히 Spring Security는 내부 규칙, 예를 들어 ROLE_ prefix 같은 디테일을 이해해야 제대로 다룰 수 있다
작아 보이는 차이 하나가 전체 인증/인가 흐름을 막을 수 있다는 점에서, 구현에서는 디테일이 정말 중요하다는 것을 다시 느꼈다.