- Published on
Spring Security OAuth2 로그인 401·invalid_token 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
Spring Security에서 OAuth2 로그인을 붙인 뒤, 브라우저에서는 로그인 성공처럼 보이는데 API 호출이 계속 401 Unauthorized로 떨어지거나, 리소스 서버(혹은 게이트웨이) 로그에 invalid_token이 찍히는 경우가 흔합니다. 문제는 대개 “토큰을 누가 발급했고(Authorization Server), 누가 검증하는지(Resource Server), 그리고 그 둘 사이 계약(issuer/audience/JWK/서명 알고리즘/시간)이 맞는지”에서 발생합니다.
이 글은 증상을 빠르게 분류하고, 로그/설정/네트워크를 따라가며 원인을 확정하는 체크리스트 형태로 정리합니다. 특히 Spring Security 5.7+ (Boot 2.7/3.x) 기준으로 oauth2Login(세션 기반)과 oauth2ResourceServer(Bearer 토큰 기반)를 혼동해서 생기는 401을 중점적으로 다룹니다.
1) 401과 invalid_token을 먼저 분리해서 생각하기
같은 401이라도 원인이 완전히 다릅니다.
1.1 oauth2Login인데 왜 Bearer 토큰을 기대하나?
oauth2Login()은 기본적으로 브라우저 세션(JSESSIONID) 기반 인증입니다.- 반면
Authorization: Bearer <token>을 검증하려면oauth2ResourceServer().jwt()또는opaqueToken()이 필요합니다.
즉, 프론트가 로그인 후 API를 호출할 때 쿠키를 보내지 않으면(또는 CORS/도메인 문제로 쿠키가 누락되면) 서버는 익명으로 보고 401을 반환합니다. 이때 로그에는 invalid_token이 아니라 단순히 “인증 정보 없음”이 찍히는 경우가 많습니다.
1.2 invalid_token은 “토큰은 왔는데 검증 실패”
invalid_token은 대개 아래 중 하나입니다.
- 토큰이 만료(exp)
- issuer 불일치(iss)
- audience 불일치(aud)
- 서명 검증 실패(JWK 키 불일치, 알고리즘 불일치)
- 시계 오차(clock skew)
- opaque token인데 JWT로 검증 시도(혹은 반대)
2) 가장 흔한 구조적 실수: 로그인과 리소스 서버 설정 혼용
Spring Boot에서 한 애플리케이션이 “로그인(클라이언트)”이면서 동시에 “API(리소스 서버)” 역할을 하면 설정이 섞이기 쉽습니다.
2.1 세션 기반 로그인 + API 보호(동일 앱) 예시
브라우저 기반 UI는 세션으로, API는 Bearer 토큰으로 보호하려면 보통 SecurityFilterChain을 분리하는 편이 안전합니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
@Order(1)
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(new JwtGrantedAuthoritiesConverter())
)
)
.csrf(csrf -> csrf.disable());
return http.build();
}
@Bean
@Order(2)
SecurityFilterChain web(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login**", "/error").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(Customizer.withDefaults());
return http.build();
}
}
/api/**는 Bearer 토큰을 요구- 그 외는
oauth2Login()으로 세션 로그인
여기서도 401이 난다면 “내 요청이 정말 /api/**로 들어가고 있는지”, “Authorization 헤더가 실제로 전달되는지”부터 확인해야 합니다.
3) 진단의 시작점: Spring Security 디버그 로그 켜기
원인을 빠르게 확정하려면 필수입니다.
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.oauth2: DEBUG
확인 포인트:
BearerTokenAuthenticationFilter가 동작했는지JwtAuthenticationProvider에서 어떤 예외가 났는지NimbusJwtDecoder가 어떤 JWK를 가져왔는지
4) invalid_token 원인별 체크리스트
4.1 issuer(iss) 불일치: 가장 흔한 설정 미스
Spring Resource Server는 기본적으로 iss를 검증합니다. IdP(예: Keycloak, Cognito, Auth0)에서 토큰에 들어간 iss와 서버 설정이 다르면 바로 실패합니다.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://idp.example.com/realms/myrealm
- 토큰을 디코드해서
iss를 확인하고(서명 검증 없이도 payload는 볼 수 있음) issuer-uri가 정확히 일치하는지(슬래시 포함 여부, realm 경로 포함 여부) 점검합니다.
4.2 JWK 키 불일치 / 키 롤오버 미반영
서명 검증 실패는 대개 JWK를 잘못 가져오거나, 키 롤오버가 있었는데 캐시가 갱신되지 않은 경우입니다.
issuer-uri를 쓰면 Spring이/.well-known/openid-configuration→jwks_uri를 따라갑니다.- 네트워크/프록시/방화벽 문제로 JWK를 못 가져오면 검증이 실패할 수 있습니다.
쿠버네티스/EKS 환경이라면 “Pod에서 egress는 되는데 특정 엔드포인트만 막히는지”도 확인해야 합니다. 이런 네트워크 계층 진단은 EKS에서 Pod egress는 되는데 ingress만 실패할 때 같은 체크리스트가 도움이 됩니다.
4.3 audience(aud) 불일치
IdP가 발급한 토큰의 aud가 API가 기대하는 값과 다르면 실패합니다. Spring은 기본으로 audience를 강제하지 않지만, 커스텀 validator를 붙였거나, 게이트웨이/미들웨어가 audience를 검증하는 경우가 많습니다.
@Bean
JwtDecoder jwtDecoder(@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer) {
NimbusJwtDecoder decoder = (NimbusJwtDecoder) JwtDecoders.fromIssuerLocation(issuer);
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer);
OAuth2TokenValidator<Jwt> withAudience = token -> {
if (token.getAudience().contains("api://my-service")) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", "Invalid audience", null));
};
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, withAudience));
return decoder;
}
이 설정을 넣었다면, 이제부터는 aud가 맞지 않는 모든 토큰이 invalid_token이 됩니다. 운영에서 갑자기 401이 증가했다면 IdP의 클라이언트 설정 변경(리소스/스코프/오디언스) 이력을 먼저 확인하세요.
4.4 clock skew(시간 오차)로 exp/nbf 검증 실패
컨테이너 노드 시간이 틀어져 있거나, IdP와 서버의 시간이 몇 분씩 차이나면 JwtValidationException이 발생합니다.
- 노드 NTP 동기화 상태 확인
- 필요한 경우 허용 오차를 늘립니다.
@Bean
JwtDecoder jwtDecoderWithSkew(@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer) {
NimbusJwtDecoder decoder = (NimbusJwtDecoder) JwtDecoders.fromIssuerLocation(issuer);
Duration skew = Duration.ofMinutes(2);
OAuth2TokenValidator<Jwt> defaultValidator = JwtValidators.createDefaultWithIssuer(issuer);
OAuth2TokenValidator<Jwt> timestampValidator = new JwtTimestampValidator(skew);
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(defaultValidator, timestampValidator));
return decoder;
}
4.5 Opaque 토큰을 JWT로 검증(또는 반대)
IdP가 발급하는 액세스 토큰이 JWT가 아닐 수 있습니다(opaque). 이 경우 oauth2ResourceServer().jwt()로는 검증이 불가능합니다.
- 토큰이
.으로 3덩어리(header.payload.signature)인지 확인 - opaque라면 introspection을 사용:
spring:
security:
oauth2:
resourceserver:
opaque-token:
introspection-uri: https://idp.example.com/oauth2/introspect
client-id: my-api
client-secret: ${INTROSPECT_SECRET}
http.oauth2ResourceServer(oauth2 -> oauth2.opaqueToken(Customizer.withDefaults()));
5) 401인데 invalid_token이 아닌 경우: 쿠키/세션/CORS
브라우저 로그인(oauth2Login) 후 API 호출이 401이면, 세션 쿠키가 전달되지 않는 케이스가 정말 많습니다.
5.1 프론트와 API 도메인이 다르면 SameSite/secure가 핵심
- 크로스사이트 요청에서 쿠키를 보내려면
SameSite=None; Secure가 필요합니다. - 프론트에서
fetch/axios에credentials: 'include'가 필요합니다.
Spring Boot 설정 예:
server:
servlet:
session:
cookie:
same-site: none
secure: true
CORS도 함께 설정해야 합니다.
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
6) 리버스 프록시/로드밸런서 뒤에서 redirect_uri 불일치
OAuth2 로그인은 최종적으로 redirect_uri가 IdP에 등록된 값과 정확히 일치해야 합니다. 프록시 뒤에서 스킴/호스트가 바뀌면(예: 내부는 http, 외부는 https) 콜백 URL이 달라져 로그인 이후 흐름이 꼬이고, 결과적으로 토큰 교환이 실패하거나 세션이 생성되지 않아 401로 이어질 수 있습니다.
해결 포인트:
X-Forwarded-*헤더를 신뢰하도록 설정- Spring Boot 3.x에서는 아래 설정을 주로 사용
server:
forward-headers-strategy: framework
또한 인그레스/ALB 설정에서 X-Forwarded-Proto: https가 제대로 들어오는지 확인하세요. 인그레스 쪽 이슈로 특정 경로만 503/라우팅 문제를 만들기도 하니, 운영 중이라면 EKS에서 Pod는 Running인데 503가 뜰 때 점검 같은 관점으로 함께 점검하는 것이 좋습니다.
7) 실전 트러블슈팅 순서(재현→확정→수정)
- 요청에 인증 정보가 있는지 확인
- 세션 기반이면 쿠키(JSESSIONID) 포함 여부
- 토큰 기반이면
Authorization: Bearer ...포함 여부
- 서버에서 어떤 필터가 처리했는지 확인
BearerTokenAuthenticationFilter동작 여부
- 토큰 자체를 확인
- JWT 형태인지(점 2개)
exp,nbf,iss,aud값
- 리소스 서버 설정 확인
issuer-uri정확성- JWK 접근 가능 여부(네트워크)
- audience validator 커스텀 여부
- 프록시/인그레스 환경 확인
forward-headers-strategy- redirect_uri 불일치
8) 빠른 점검용: JWT 디코드 스니펫(로컬 확인)
운영 로그만으로 부족할 때, 토큰 payload를 빠르게 확인하는 간단한 자바 코드입니다(서명 검증은 하지 않음).
import java.util.*;
public class JwtPeek {
public static void main(String[] args) {
String jwt = System.getenv("JWT");
String[] parts = jwt.split("\\.");
String payloadJson = new String(Base64.getUrlDecoder().decode(parts[1]));
System.out.println(payloadJson);
}
}
이걸로 iss/aud/exp를 확인한 뒤, 위 체크리스트에 대입하면 대부분의 invalid_token은 원인이 바로 좁혀집니다.
결론
Spring Security OAuth2에서 401과 invalid_token은 “인증 정보가 아예 없어서 실패”인지, “토큰 검증 계약이 깨져서 실패”인지부터 분리해야 해결이 빨라집니다. oauth2Login(세션)과 oauth2ResourceServer(Bearer) 경계를 명확히 하고, issuer-uri/JWK/audience/clock skew/redirect_uri 다섯 가지를 순서대로 확인하면 재현 가능한 형태로 원인을 확정할 수 있습니다.
운영 환경(EKS/Ingress/ALB)에서는 네트워크와 헤더 전달(X-Forwarded-Proto)이 토큰 검증 실패처럼 보이게 만들기도 하므로, 애플리케이션 설정과 인프라 설정을 함께 보는 습관이 중요합니다.