OAuth 2.1 무엇이 달라지나: PKCE 의무화와 Implicit 퇴출 (2026년 기준)

OAuth 2.1이 바꾸는 핵심은 세 가지입니다. 모든 클라이언트에 PKCE를 의무화하고, Implicit과 ROPC를 제거하며, 리다이렉트 URI를 문자열 단위로 정확히 일치시킵니다. 새 기능을 더한 것이 아니라, 지난 10여 년간 쌓인 보안 권고를 하나의 사양으로 합친 정리 작업입니다.

먼저 짚어야 할 것이 있습니다. 2026년 9월 현재 OAuth 2.1은 아직 RFC가 아닙니다. 여전히 IETF 초안 단계입니다. 그런데도 지금 대응해야 하는 이유까지 아래에서 정리합니다.

OAuth 2.1의 표준화 현황 — 아직 초안이다

많은 글이 OAuth 2.1을 확정된 표준처럼 소개하지만 사실과 다릅니다. 현재 상태는 다음과 같습니다.

사양 문서 번호 상태
OAuth 1.0a RFC 5849 2010년 4월 발행 (2007년 커뮤니티 사양이 원형)
OAuth 2.0 RFC 6749 / RFC 6750 2012년 10월 발행
OAuth 2.0 Security BCP RFC 9700 (BCP 240) 2025년 1월 발행
OAuth 2.1 draft-ietf-oauth-v2-1-15 IETF 초안 (2026년 3월 개정, Standards Track 목표)

완성되면 RFC 6749와 RFC 6750을 대체하는 게 목표입니다. 아직 초안인데도 미룰 이유가 없는 건, 담고 있는 내용이 대부분 이미 RFC 9700으로 확정된 모범 사례이기 때문입니다. OAuth 2.1을 기다릴 필요 없이 RFC 9700만 따라가도 실질적으로 같은 자리에 도착합니다.

OAuth는 어떤 문제를 풀려고 나왔나

OAuth 이전의 웹에는 고약한 관행이 있었습니다. A 서비스의 데이터를 B 서비스에서 쓰려면 B에게 A의 아이디와 비밀번호를 통째로 넘겨야 했습니다. 이른바 패스워드 공유 안티패턴입니다.

여기서 세 가지 문제가 동시에 터집니다.

  • 과도한 권한 노출 — 주소록만 읽으면 되는데 계정 전체를 넘기게 됩니다.
  • 권한 회수 불가 — 특정 서비스의 접근만 끊을 방법이 없어, 비밀번호를 바꾸면 연동된 모든 서비스가 함께 끊깁니다.
  • 신뢰의 전이 — 한 곳이 털리면 그 비밀번호로 연결된 모든 계정이 함께 뚫립니다.

OAuth는 진짜 열쇠 대신 제한된 임시 출입증을 발급하는 방식으로 이걸 해결합니다. 두 가지 비유가 정확합니다.

  • 발렛 키 — 차주는 주차 요원에게 시동만 걸 수 있는 키를 줍니다. 운전은 되지만 트렁크는 열리지 않습니다. 이것이 권한 범위(scope) 입니다.
  • 호텔 키카드 — 내 방만 열리고, 투숙 기간이 끝나면 무용지물이 됩니다. 연장하려면 다시 발급받아야 합니다. 이것이 만료와 갱신(refresh) 입니다.

OAuth 1.0 — 서명의 시대와 그 대가

RFC 5849로 정리된 OAuth 1.0a는 HTTPS가 보편적이지 않던 시절에 설계됐습니다. 전송 계층을 믿을 수 없으니 애플리케이션 레벨에서 요청마다 서명을 만들었습니다.

클라이언트는 요청마다 타임스탬프와 논스(nonce)를 붙이고, 파라미터를 사전순으로 정렬해 정규화한 뒤 HMAC-SHA1으로 서명해야 했습니다. 보안은 단단했지만 대가가 컸습니다.

  • 파라미터 정렬 순서나 URL 인코딩 방식이 조금만 어긋나도 서명이 깨집니다. 디버깅이 극도로 어렵습니다.
  • 클라이언트마다 서명 구현을 새로 해야 하니 라이브러리 의존도가 높았습니다.
  • 브라우저가 아닌 환경(모바일 앱, 서버 간 통신)에 대한 고려가 부족했습니다.

OAuth 2.0 — 서명을 버리고 TLS에 맡기다

2012년 RFC 6749로 나온 OAuth 2.0은 1.0과의 호환성을 과감히 버렸습니다. 가장 큰 결정은 서명을 없애고 보안을 HTTPS에 위임한 것입니다. 클라이언트는 복잡한 연산 없이 베어러 토큰을 HTTP 헤더에 담아 보내기만 하면 됩니다.

GET /api/v1/me HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

이름 그대로 베어러(bearer, 소지자) 토큰입니다. 가진 사람이 곧 권한자이므로, 탈취되면 그대로 뚫립니다. 서명을 버린 대가가 여기 있습니다. OAuth 2.1의 변경 사항 대부분이 결국 이 지점을 보완하는 작업입니다.

OAuth 2.0은 또한 단일 프로토콜이 아니라 프레임워크를 지향했습니다. 역할을 자원 소유자·클라이언트·인가 서버·자원 서버로 나누고, 환경에 따라 네 가지 권한 부여 방식을 제공했습니다.

Grant Type 용도 2.1에서의 처리
Authorization Code 서버 사이드 웹앱 유지 (PKCE 필수)
Implicit SPA용으로 고안 제거
ROPC 사용자가 앱에 ID/PW 직접 입력 제거
Client Credentials 사용자 개입 없는 서버 간 통신 유지

OAuth 2.1이 바꾸는 것 다섯 가지

1. PKCE 전면 의무화

과거 모바일 앱에만 권장되던 PKCE(RFC 7636)가 모든 클라이언트에 필수가 됩니다. 서버 사이드 웹앱처럼 클라이언트 시크릿을 안전하게 보관할 수 있는 경우에도 적용해야 합니다.

2. Implicit Grant 제거

Implicit은 액세스 토큰을 리다이렉트 URL의 프래그먼트(#)에 실어서 브라우저에 직접 돌려주는 방식이었습니다. 토큰이 브라우저 히스토리, 리퍼러 헤더, 서버 로그에 남을 수 있습니다. SPA는 이제 PKCE를 적용한 Authorization Code를 써야 합니다.

3. ROPC 제거

사용자가 서드파티 앱에 직접 아이디와 비밀번호를 입력하는 방식입니다. OAuth가 애초에 없애려던 바로 그 패턴이고, 다단계 인증(MFA)이나 소셜 로그인과 함께 쓸 수 없습니다.

4. 리다이렉트 URI 정확 일치

와일드카드나 접두어 매칭을 허용하지 않고 문자열 단위 완전 일치만 인정합니다. 느슨한 검증은 오픈 리다이렉트를 통한 인가 코드 탈취 경로가 됩니다.

등록된 URI : https://app.example.com/callback

허용   https://app.example.com/callback
거부   https://app.example.com/callback/
거부   https://app.example.com/callback?x=1
거부   https://app.example.com.evil.com/callback

5. 리프레시 토큰 순환

공개 클라이언트(브라우저·모바일처럼 시크릿을 숨길 수 없는 경우)는 리프레시 토큰을 쓸 때 순환(rotation) 이 필수입니다. 갱신할 때마다 기존 토큰을 폐기하고 새로 발급합니다.

핵심은 탈취 탐지입니다. 이미 폐기된 리프레시 토큰이 다시 들어오면, 공격자와 정상 사용자 중 한쪽이 쓴 것이므로 해당 토큰 계열 전체를 무효화합니다.

PKCE는 실제로 어떻게 동작하나

PKCE가 막는 공격은 구체적입니다. 모바일에서 커스텀 URL 스킴(myapp://callback)은 여러 앱이 등록할 수 있어서, 악성 앱이 인가 코드를 가로챌 수 있습니다. 코드만 있으면 토큰으로 바꿀 수 있으니 그대로 뚫립니다.

PKCE는 여기에 “코드를 요청한 자와 토큰을 바꾸러 온 자가 같은가”를 증명하는 단계를 넣습니다.

  1. 클라이언트가 난수 code_verifier를 만들어 자기 메모리에만 보관합니다.
  2. 인가 요청에는 그 해시인 code_challenge만 보냅니다.
  3. 토큰 교환 시 원본 code_verifier를 제출합니다.
  4. 인가 서버가 해시를 다시 계산해 대조합니다.

코드를 가로챈 공격자는 code_verifier를 모르므로 토큰을 받지 못합니다.

Java로 생성하면 이렇습니다. code_verifier는 43~128자, code_challenge는 그 SHA-256 해시를 Base64URL로 인코딩한 값입니다.

import java.security.MessageDigest;
import java.security.SecureRandom;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

// 1) code_verifier — 43~128자의 무작위 문자열 (RFC 7636)
byte[] randomBytes = new byte[32];
new SecureRandom().nextBytes(randomBytes);
String codeVerifier = Base64.getUrlEncoder()
        .withoutPadding()
        .encodeToString(randomBytes);   // 43자

// 2) code_challenge = BASE64URL( SHA256(code_verifier) )
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashed = digest.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
String codeChallenge = Base64.getUrlEncoder()
        .withoutPadding()
        .encodeToString(hashed);

인가 요청에는 code_challenge와 방식을 함께 보냅니다.

GET /authorize
  ?response_type=code
  &client_id=my-client
  &redirect_uri=https://app.example.com/callback
  &scope=read:profile
  &state=xyz
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256

code_challenge_method는 반드시 S256 을 씁니다. 사양에 plain도 있지만 code_verifier를 그대로 노출하므로 의미가 없습니다.

Spring Security에서 PKCE 적용하기

Spring Security 5.7 이상은 PKCE를 지원합니다. 다만 공개 클라이언트(client-secret 미설정)에만 자동으로 적용됩니다. 시크릿이 있는 서버 사이드 앱은 기본적으로 PKCE가 꺼지므로, OAuth 2.1 기준에 맞추려면 명시적으로 켜야 합니다.

import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;

@Configuration
public class OAuth2ClientConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http,
                                    ClientRegistrationRepository repo) throws Exception {
        http.oauth2Login(oauth2 -> oauth2
                .authorizationEndpoint(endpoint ->
                        endpoint.authorizationRequestResolver(pkceResolver(repo))));
        return http.build();
    }

    private OAuth2AuthorizationRequestResolver pkceResolver(ClientRegistrationRepository repo) {
        var resolver = new DefaultOAuth2AuthorizationRequestResolver(
                repo, "/oauth2/authorization");
        // 클라이언트 시크릿이 있어도 PKCE를 강제한다
        resolver.setAuthorizationRequestCustomizer(
                OAuth2AuthorizationRequestCustomizers.withPkce());
        return resolver;
    }
}

적용됐는지는 인가 요청 URL에 code_challenge 파라미터가 붙는지로 확인하면 됩니다. 브라우저 개발자 도구의 네트워크 탭에서 /oauth2/authorization/{registrationId} 요청의 리다이렉트 대상을 보면 바로 보입니다.

인가 서버가 PKCE를 지원하지 않으면 code_challenge를 무시하고 그냥 동작합니다. 조용히 넘어가므로, 인가 서버의 메타데이터 문서(/.well-known/oauth-authorization-server)에서 code_challenge_methods_supported 항목을 반드시 확인하세요.

지금 내 서비스 점검 목록

  • response_type=token(Implicit)을 쓰는 클라이언트가 남아 있는가
  • grant_type=password(ROPC)를 쓰는 내부 앱이나 배치가 있는가
  • ☐ 모든 인가 요청에 code_challenge가 붙는가
  • code_challenge_methodS256인가 (plain 아님)
  • ☐ 등록된 리다이렉트 URI에 와일드카드나 접두어 매칭이 있는가
  • ☐ 공개 클라이언트의 리프레시 토큰이 갱신 시 순환되는가
  • ☐ 폐기된 리프레시 토큰 재사용 시 토큰 계열 전체를 무효화하는가
  • ☐ 액세스 토큰이 URL 쿼리스트링이 아니라 Authorization 헤더로 전달되는가

Implicit이나 ROPC를 쓰고 있다면 마이그레이션 경로는 정해져 있습니다. 둘 다 PKCE를 적용한 Authorization Code로 옮기면 됩니다. SPA는 인가 코드를 백엔드에서 교환하는 BFF(Backend For Frontend) 패턴을 함께 검토할 만합니다. 토큰을 브라우저에 두지 않는 편이 안전합니다.

AI 에이전트가 OAuth 2.1을 쓰는 이유

OAuth 2.1의 영향은 웹과 모바일을 넘어섭니다. AI 에이전트가 외부 데이터에 접근하는 표준인 MCP(Model Context Protocol) 의 인가 사양이 OAuth 2.1을 기반으로 합니다.

MCP 사양은 클라이언트에 PKCE 구현을 의무화하고, 기술적으로 가능하면 S256 방식을 쓰도록 요구합니다. 나아가 인가 서버 메타데이터에 code_challenge_methods_supported가 없으면 진행을 거부하라고 명시합니다. MCP 서버는 OAuth 2.1의 자원 서버(Resource Server)로 모델링됩니다.

왜 이렇게까지 엄격할까요. 에이전트는 사용자를 대신해 자율적으로 여러 서비스를 호출합니다. 권한이 느슨하면 혼동된 대리인 문제(Confused Deputy Problem) — 정당한 권한을 가진 주체가 공격자에게 속아 대신 나쁜 일을 하는 상황 — 가 그대로 발생합니다. 사람이 매번 확인하지 않는 만큼, 위임 범위를 좁히고 토큰의 대상을 명시하는 일이 더 중요해집니다.

정리

  • OAuth 2.1은 2026년 9월 현재 IETF 초안입니다. RFC가 아닙니다.
  • 그러나 내용의 대부분은 이미 RFC 9700(BCP 240, 2025년 1월) 으로 확정됐습니다. 기다릴 이유가 없습니다.
  • 당장 할 일은 셋입니다. PKCE를 모든 클라이언트에 적용하고, Implicit·ROPC를 걷어내고, 리다이렉트 URI를 정확 일치로 조이는 것입니다.

지금 인가 요청 URL을 열어 code_challenge가 붙어 있는지부터 확인해 보세요. 없다면 거기서 시작하면 됩니다.

자주 묻는 질문

Q. OAuth 2.1이 RFC가 되면 OAuth 2.0은 못 쓰게 되나요? A. 그렇지 않습니다. OAuth 2.1은 RFC 6749를 형식상 대체(obsolete)하는 것을 목표로 하지만, 기존 구현이 갑자기 동작을 멈추지는 않습니다. 다만 Implicit과 ROPC는 주요 인가 서버들이 이미 지원을 줄이고 있어, 사양과 무관하게 실질적인 사용 종료가 진행 중입니다.

Q. 서버 사이드 웹앱도 정말 PKCE가 필요한가요? 클라이언트 시크릿이 있는데요. A. 네. 클라이언트 시크릿은 토큰 교환 단계에서 클라이언트를 인증할 뿐, 인가 코드가 리다이렉트 과정에서 주입되는 공격(authorization code injection)은 막지 못합니다. PKCE는 그 지점을 막습니다. OAuth 2.1과 RFC 9700 모두 클라이언트 유형과 무관하게 PKCE를 요구합니다.

Q. PKCE를 쓰면 state 파라미터는 안 써도 되나요? A. 용도가 다릅니다. PKCE는 인가 코드 가로채기와 주입을 막고, state는 CSRF 방지와 인증 후 원래 페이지로 돌아가기 위한 값입니다. PKCE가 CSRF 방어까지 겸할 수 있지만, 리다이렉트 후 상태 복원이 필요하다면 state는 계속 필요합니다.

참고 자료

함께 읽으면 좋은 글

“OAuth 2.1 무엇이 달라지나: PKCE 의무화와 Implicit 퇴출 (2026년 기준)”에 대한 2개의 생각

댓글 남기기