[Spring Security] OAuth2 Authorization Code Flow + PKCE
정의
spring-security 가 OAuth2 Login (소셜 로그인) 의 개요를 다룬다면, 이 페이지는 표준 Authorization Code Grant 의 전체 흐름과 PKCE, refresh token, state, OAuth2 Resource Server 구성을 깊이 다룬다.
OAuth2 4가지 grant 중 Authorization Code + PKCE 가 현대 권장 (RFC 8252):
- 웹 / 모바일 / SPA 모두 동일
- Implicit Flow 는 deprecated (보안 문제)
- Resource Owner Password Credentials 는 legacy 만
- Client Credentials 는 machine-to-machine
Authorization Code Flow 전체 흐름
[1] 사용자 → /login/oauth2/authorization/google
[2] 서버 → 302 → IdP authorize endpoint
?response_type=code
&client_id=...
&redirect_uri=https://app/login/oauth2/code/google
&scope=openid profile email
&state=<random>
&code_challenge=<sha256(verifier)>
&code_challenge_method=S256
[3] 사용자 IdP 로그인 + 동의
[4] IdP → 302 → redirect_uri?code=<short-lived>&state=<echo>
[5] 서버 state 검증
[6] 서버 → IdP token endpoint (back-channel)
POST grant_type=authorization_code, code, code_verifier, client_id, client_secret
[7] IdP → access_token, refresh_token, id_token
[8] 서버 access_token 으로 /userinfo 또는 id_token 파싱 → 사용자 정보
[9] 서버 → 세션 생성 / JWT 발급 → 클라이언트
핵심 보안 장치:
- state: CSRF + 세션 매칭. random 한 nonce 를 발급, callback 에서 검증
- PKCE:
code가로채기 방어. verifier (random) → challenge (sha256) → token 교환 시 verifier 제출 - redirect_uri 정확 매칭: IdP 에 등록된 URI 와 정확 일치해야 함
Spring Security 설정 (Client)
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: openid, profile, email
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
authorization-grant-type: authorization_code
client-authentication-method: client_secret_basic
provider:
google:
authorization-uri: https://accounts.google.com/o/oauth2/v2/auth
token-uri: https://oauth2.googleapis.com/token
user-info-uri: https://openidconnect.googleapis.com/v1/userinfo
jwk-set-uri: https://www.googleapis.com/oauth2/v3/certs
issuer-uri: https://accounts.google.com # 있으면 다른 URI 들 자동 발견
issuer-uri 만 있어도 OIDC discovery (.well-known/openid-configuration) 로 자동 설정.
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(a -> a
.requestMatchers("/", "/public/**").permitAll()
.anyRequest().authenticated())
.oauth2Login(o -> o
.defaultSuccessUrl("/home", true)
.failureUrl("/login?error"))
.logout(l -> l.logoutSuccessUrl("/"))
.build();
}
/login/oauth2/authorization/google 으로 이동만 시키면 Spring Security 가 위 9단계를 다 처리.
PKCE 활성화
Spring Security 6.0+ 는 public client (client-secret 없음) 에선 자동 활성화. confidential client 도 활성화 가능:
spring:
security:
oauth2:
client:
registration:
google:
client-authentication-method: none # public client
# 또는 client_secret_basic + PKCE 명시 (provider 가 지원할 때)
@Bean
OAuth2AuthorizedClientManager manager(...) {
OAuth2AuthorizedClientProvider providers =
OAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken()
.build();
// PKCE 는 authorizationCode() 내부에서 random verifier 생성
...
}
SPA / 모바일이 client_secret 을 안전하게 보관 못 하므로 (코드에 박혀 노출) PKCE 가 필수 (RFC 8252).
state 처리
Spring Security 가 자동으로 random state 발급 + session 에 저장 + callback 에서 비교. 직접 코드 작성 불필요.
state 유실 (세션 만료, 다른 브라우저) 시 OAuth2AuthorizationException. failureUrl 에서 안내.
ID Token (OIDC)
scope=openid 면 token 응답에 id_token 도 포함:
{
"access_token": "ya29...",
"refresh_token": "1//...",
"id_token": "eyJhbGc..."
}
id_token 은 JWT. 서명 검증 후 sub (사용자 ID), email, name 같은 claim 추출.
OAuth2User 또는 OidcUser 로 주입:
@RestController
class UserController {
@GetMapping("/me")
public Map<String, Object> me(@AuthenticationPrincipal OidcUser user) {
return Map.of(
"sub", user.getSubject(),
"email", user.getEmail(),
"name", user.getFullName(),
"scopes", user.getAuthorities()
);
}
}{
"sub": "108273...",
"email": "alice@example.com",
"name": "Alice Park",
"scopes": ["SCOPE_openid", "SCOPE_profile", "SCOPE_email"]
}Refresh Token Rotation
refresh_token 으로 access_token 만료 시 재발급:
@Service
class TokenRefreshService {
private final OAuth2AuthorizedClientManager manager;
public String currentAccessToken(Authentication auth, String registrationId) {
OAuth2AuthorizeRequest req = OAuth2AuthorizeRequest.withClientRegistrationId(registrationId)
.principal(auth)
.build();
OAuth2AuthorizedClient client = manager.authorize(req);
return client.getAccessToken().getTokenValue();
}
}
내부적으로 expiry 검사 → 만료 시 refresh_token 으로 자동 갱신. 일부 IdP 는 rotation 적용 (refresh 사용 시 새 refresh_token 발급, 기존 invalidate). Spring Security 가 자동 처리.
Resource Server (JWT 검증)
다른 서비스가 IdP 가 발급한 access_token 으로 API 호출:
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://accounts.google.com
# 또는 직접 jwk-set-uri 지정
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(a -> a
.requestMatchers(HttpMethod.GET, "/api/**").hasAuthority("SCOPE_read")
.requestMatchers(HttpMethod.POST, "/api/**").hasAuthority("SCOPE_write")
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthConverter())))
.build();
}
@Bean
JwtAuthenticationConverter jwtAuthConverter() {
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
scopes.setAuthorityPrefix("SCOPE_");
scopes.setAuthoritiesClaimName("scope");
JwtAuthenticationConverter conv = new JwtAuthenticationConverter();
conv.setJwtGrantedAuthoritiesConverter(scopes);
return conv;
}
- JWT signature 검증 (issuer 의 JWKS 사용)
- expiration 체크
- audience 체크
- scope → authority 변환
JWKS 는 자동 캐싱 + 주기적 갱신. issuer 가 keys rotation 해도 영향 없음.
Custom claim 매핑
기업 IdP 가 roles claim 을 별도 형식으로 보낼 때:
@Bean
JwtAuthenticationConverter jwtAuthConverter() {
return new JwtAuthenticationConverter() {{
setJwtGrantedAuthoritiesConverter(jwt -> {
Collection<String> roles = jwt.getClaimAsStringList("realm_access.roles");
return roles == null ? List.of() :
roles.stream().map(r -> new SimpleGrantedAuthority("ROLE_" + r)).toList();
});
}};
}
Keycloak / Azure AD / Auth0 등의 nested claim 처리.
Opaque Token (introspection)
IdP 가 JWT 가 아닌 random string 발급 시 (Spring Authorization Server, GitHub) introspection 사용:
spring:
security:
oauth2:
resourceserver:
opaquetoken:
introspection-uri: https://auth-server/oauth2/introspect
client-id: api-client
client-secret: ...
매 요청마다 IdP 호출 → 캐싱 (TTL) 필수.
실전 함정
redirect_uri 의 정확 일치
IdP 에 https://app.example.com/login/oauth2/code/google 등록. 한 글자라도 다르면 redirect_uri_mismatch. localhost 개발 시 별도 등록 필요.
CORS 와 PKCE
SPA 가 IdP 의 authorize 엔드포인트로 직접 갈 때 CORS preflight 필요 (일부 IdP). 대안: 백엔드가 redirect 만 발급 (BFF 패턴).
Refresh token storage
브라우저 SPA 가 refresh token 보관하면 XSS 위험. BFF (Backend for Frontend) 패턴 권장: 백엔드가 session + token 보관, SPA 는 session cookie 만.
state 가 같은 브라우저 다른 탭 충돌
여러 탭에서 동시 OAuth2 시작 시 state 가 서로 덮어쓸 수 있음. Spring Security 가 OAuth2AuthorizationRequestRepository 로 처리하지만 sticky session 필수.
Logout
SecurityContextLogoutHandler 는 로컬 세션만 종료. IdP 세션은 별도 (end_session_endpoint). OIDC RP-Initiated Logout 지원:
spring:
security:
oauth2:
client:
provider:
keycloak:
issuer-uri: ...
.logout(l -> l.logoutSuccessHandler(new OidcClientInitiatedLogoutSuccessHandler(registry)
.setPostLogoutRedirectUri("{baseUrl}/")))
베스트 프랙티스
- 새 프로젝트는 PKCE + Authorization Code 만 사용
- public client 는 client_secret 안 씀: PKCE 만으로 충분
- state 검증은 framework 가: 직접 작성 금지
- redirect_uri 는 production / dev 별도 등록
- scope 최소화: 필요한 것만 요청 (consent 화면이 짧으면 사용자 신뢰 ↑)
- token 저장: server-side session (BFF 패턴) > httpOnly cookie > localStorage
- issuer-uri 만으로 시작: OIDC discovery 가 알아서
관련 위키
이 글의 용어 (6개)
- [Java] Servlet Filterjava
- 정의 Filter 는 servlet 호출 전/후 에 끼어들어 요청과 응답을 가로채는 컴포넌트. 인증, 로깅, 압축, CORS, 인코딩 변환 같은 횡단 관심사 (cross-cutt…
- [Spring Security] Session Management와 Remember-Mespring
- 정의 가 JWT / stateless 위주를 다룬다면, 이 페이지는 session-based 인증 의 세부 동작 (생성 정책, 동시 세션 제한, fixation 방어, remem…
- [Spring Security] Testing: @WithMockUser, MockMvc, jwt(), oauth2Login()spring
- 정의 Spring Security 가 적용된 컨트롤러를 테스트할 때 흔한 어려움: - 매번 실제 로그인 절차를 거쳐야 인증된 요청 가능 - CSRF token 이 없으면 POST…
- [Spring] RestClient, WebClient, RestTemplatespring
- 정의 Spring HTTP 클라이언트 3종: - RestTemplate: 동기, 전통 (5.0+ maintenance) - WebClient: reactive, async/syn…
- [Spring] Security: Filter Chain, JWT, OAuth2spring
- 정의 Spring Security는 인증·인가의 사실상 표준. 서블릿 필터 체인 위에 다양한 인증/권한 처리를 모듈식으로. 6.0+ 부터 Lambda DSL 표준, Bean 방식…
- [Spring] Spring MVC: @RestController, @RequestMappingspring
- 정의 Spring MVC는 Servlet 기반 동기 웹 프레임워크. 이 요청을 받아 핸들러(컨트롤러 메서드)에 라우팅. REST API와 전통 MVC(HTML) 모두 지원. We…
💬 댓글