본문으로 건너뛰기
김신건의 로그

[Spring Security] OAuth2 Authorization Code Flow + PKCE

· 수정 · 📖 약 3분 · 905자/단어 #spring #security #oauth2 #authorization-code #pkce #jwt
OAuth2 Authorization Code, OAuth2 Client, PKCE, OAuth2 Resource Server, Authorization Code Grant, OAuth2 Flow, 스프링 OAuth2

정의

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 로 주입:

java
@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…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기