OAuth Authorization Code + PKCE 흐름 단계별로 이해하기

OAuth Authorization Code + PKCE 흐름 단계별로 이해하기

OAuth를 배우다 보면 결국 이 문장을 자주 만나게 됩니다.

사용자 로그인이 있으면 인가 코드(Authorization Code) + PKCE를 쓰세요.

맞는 말인데, 처음 구현할 때는 막상 손에 잘 잡히지 않습니다. code는 어디서 오고, code_verifier는 누가 들고 있어야 하고, state는 PKCE와 다른 건지 헷갈리거든요. 동의 화면까지는 잘 뜨는데 콜백에서 invalid_grant가 나거나, 토큰 교환에서 redirect_uri가 다르다고 거부되는 일도 흔합니다.

이 글에서는 인가 코드 + PKCE 흐름을 요청 하나하나로 쪼개서 따라가 보겠습니다. OAuth 전체 개념이 낯설다면 먼저 OAuth 2.0 쉽게 이해하기를 읽고 오시면 좋습니다. 여러 OAuth grant type 중 무엇을 골라야 하는지부터 정리하고 싶다면 OAuth Grant Type 한 번에 정리하기가 더 좋은 출발점입니다. 여기서는 “사용자가 브라우저에서 로그인하고 동의하는 상황” 하나에 집중해서, 실제 값들이 어느 단계에서 만들어지고 검증되는지 살펴보겠습니다.

왜 이 흐름이 기본값일까요?

인가 코드 흐름의 핵심은 액세스 토큰(access token)을 브라우저 앞쪽 채널(front channel)에 직접 노출하지 않는 것입니다. 사용자는 브라우저를 통해 인가 서버(Authorization Server)로 이동하고, 로그인과 동의를 마친 뒤 클라이언트의 리디렉션 URI(redirect URI)로 돌아옵니다. 이때 돌아오는 값은 액세스 토큰이 아니라 짧게 사는 일회용 인가 코드입니다.

그다음 클라이언트가 토큰 엔드포인트(token endpoint)로 직접 요청을 보내 code를 액세스 토큰으로 교환합니다. 이 교환은 사용자의 브라우저를 거치지 않는 뒤쪽 채널(back channel)에서 일어나기 때문에 토큰이 주소창, 히스토리, Referer 헤더에 노출될 위험이 줄어듭니다.

여기에 PKCE가 붙으면 한 단계 더 안전해집니다. 클라이언트는 인가 요청을 보내기 전에 code_verifier라는 무작위 비밀값을 만들고, 그 해시값인 code_challenge만 인가 서버에 먼저 보냅니다. 나중에 code를 토큰으로 바꿀 때 원본 code_verifier를 제출하면 인가 서버가 둘을 비교합니다. 누군가 authorization code만 가로채도 code_verifier를 모르면 토큰 교환에 실패하는 구조입니다.

그래서 현대적인 OAuth에서는 웹 서버 애플리케이션, SPA, 모바일 앱 모두 Authorization Code + PKCE를 기본값으로 봅니다. OAuth 2.0 원본인 RFC 6749는 Authorization Code 흐름을 정의했고, RFC 7636이 PKCE를 추가했습니다. 이후 보안 권고와 OAuth 2.1 드래프트는 Implicit이나 Password Grant 대신 Authorization Code + PKCE를 쓰는 방향으로 정리되고 있습니다.

등장인물부터 맞추기

흐름을 보기 전에 네 역할을 먼저 맞춰두겠습니다.

사용자는 데이터의 주인입니다. 예를 들어 구글 캘린더 일정을 읽도록 허용하는 실제 사람입니다.

클라이언트(Client) 는 사용자의 데이터에 접근하려는 애플리케이션입니다. 웹앱, SPA, 모바일 앱, 데스크톱 앱이 모두 클라이언트가 될 수 있습니다. 여기서 클라이언트라는 말은 브라우저 프론트엔드만 뜻하지 않습니다. OAuth 관점에서는 “토큰을 받아 API를 호출하려는 애플리케이션” 전체를 말합니다.

인가 서버(Authorization Server) 는 사용자를 로그인시키고, 동의를 받고, 토큰을 발급합니다. 구글 로그인 화면이나 GitHub 권한 허용 화면을 떠올리면 됩니다.

자원 서버(Resource Server) 는 access token을 받아 실제 API 요청에 응답하는 서버입니다. 구글 캘린더 API, GitHub API, 사내 사용자 API 같은 곳이 여기에 해당합니다.

한 가지 더 구분하면 좋습니다. 서버에 secret을 안전하게 숨길 수 있는 클라이언트를 confidential client라고 부르고, SPA나 모바일 앱처럼 secret을 숨기기 어려운 클라이언트를 public client라고 부릅니다. PKCE는 원래 public client를 보호하려고 만들어졌지만, 이제는 confidential client에도 기본으로 붙이는 보안 장치로 보는 편이 좋습니다.

전체 흐름 한눈에 보기

먼저 전체 그림을 Mermaid 다이어그램으로 보겠습니다.

sequenceDiagram
    autonumber
    participant U as 사용자
    participant B as 브라우저
    participant C as 클라이언트
    participant AS as 인가 서버
    participant RS as 자원 서버

    C->>C: state, code_verifier 생성
    C->>C: code_verifier를 SHA-256으로 해시해 code_challenge 생성
    C->>B: /authorize로 리다이렉트
    B->>AS: 인가 요청 전달<br/>response_type=code<br/>state, code_challenge 포함
    AS->>U: 로그인과 동의 화면 표시
    U-->>AS: 로그인과 권한 허용
    AS-->>B: redirect_uri로 리다이렉트<br/>code, state 포함
    B-->>C: 콜백 요청 전달
    C->>C: state 검증
    C->>AS: /token 요청<br/>code, code_verifier 포함
    AS->>AS: code와 code_verifier 검증
    AS-->>C: access token 발급
    C->>RS: Authorization: Bearer access_token
    RS-->>C: 보호된 자원 응답

이 다이어그램에서 눈여겨볼 축은 두 개입니다.

첫째, 브라우저를 지나가는 값과 지나가지 않는 값을 구분해야 합니다. code_challenge, state, authorization code는 브라우저를 지나갑니다. 반면 access token과 code_verifier는 브라우저 주소창에 실리면 안 됩니다.

둘째, 검증 지점이 다릅니다. state는 클라이언트가 콜백에서 검증합니다. PKCE의 code_verifier는 인가 서버가 토큰 교환 단계에서 검증합니다. 둘 다 무작위 문자열이라 비슷해 보이지만 막는 공격이 다릅니다.

1단계: state와 PKCE 값 만들기

흐름은 사용자를 인가 서버로 보내기 전에 클라이언트가 무작위 값을 만드는 것에서 시작합니다. 보통 두 값을 준비합니다.

state는 CSRF 방어용 값입니다. 클라이언트가 인가 요청에 state를 실어 보내고, 콜백으로 같은 값이 돌아오는지 확인합니다. 값이 다르거나 없으면 내가 시작한 로그인 흐름이 아니라고 보고 중단해야 합니다.

code_verifier는 PKCE용 비밀값입니다. RFC 7636에 따르면 43자 이상 128자 이하의 충분히 무작위인 문자열이어야 합니다. 클라이언트는 이 값을 그대로 보관하고, SHA-256으로 해시한 뒤 Base64URL로 인코딩한 code_challenge를 만듭니다. 현대적인 구현에서는 code_challenge_methodS256을 사용합니다. plain 방식도 스펙에는 남아 있지만, 보안 효과가 약하므로 새 구현에서 선택할 이유가 거의 없습니다.

간단히 표현하면 다음과 같습니다.

PKCE 값 생성 예시
const state = crypto.randomUUID();
const codeVerifier = generateRandomString();
const codeChallenge = base64UrlEncode(
  await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier)),
);

실제 구현에서는 statecode_verifier를 현재 로그인 시도와 묶어서 저장해야 합니다. 서버 렌더링 웹앱이라면 서버 세션이나 암호화된 쿠키를 사용할 수 있고, SPA라면 sessionStorage를 쓰는 경우가 많습니다. 여기서 놓치면 안 되는 점은 code_verifier를 인가 요청 URL에 넣지 않는 것입니다. 인가 서버에는 code_challenge만 먼저 보냅니다.

2단계: 인가 요청 보내기

값을 준비했다면 사용자를 인가 서버의 인가 엔드포인트(authorization endpoint)로 보냅니다. 경로 이름은 서비스마다 다를 수 있지만, 예시에서는 관용적으로 /authorize를 쓰겠습니다. 실제 서비스에서는 OAuth 2.0 메타데이터와 엔드포인트 동적 발견에서 다룬 .well-known 메타데이터를 통해 엔드포인트를 찾는 방식도 많이 씁니다.

인가 요청
GET /authorize?
  response_type=code
  &client_id=calendar-client
  &redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
  &scope=calendar.read
  &state=af0ifjsldkj
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256 HTTP/1.1
Host: auth.example.com

response_type=code는 Authorization Code 흐름을 시작한다는 뜻입니다. client_id는 사전에 등록된 클라이언트 식별자입니다. 비밀값이 아니라 공개 식별자이므로 브라우저에 노출되어도 됩니다.

redirect_uri는 인가가 끝난 뒤 사용자를 돌려보낼 주소입니다. 이 값은 클라이언트 등록 시 허용한 값과 정확히 일치해야 합니다. 스킴, 호스트, 경로, 끝 슬래시, URL 인코딩 차이 때문에 거부되는 일이 많으니 디버깅할 때 가장 먼저 확인해야 하는 값입니다.

scope은 요청할 권한 범위입니다. 예를 들어 calendar.read는 캘린더 읽기 권한을 뜻할 수 있습니다. 스코프 이름은 서비스마다 다르며, 사용자는 동의 화면에서 이 앱이 어떤 권한을 요청하는지 확인합니다.

state는 앞에서 만든 CSRF 방어용 값이고, code_challengecode_challenge_method는 PKCE 값입니다. 인가 서버는 이 code_challenge를 나중에 발급할 authorization code와 연결해서 저장해둡니다. 그래야 토큰 교환 단계에서 제출된 code_verifier와 비교할 수 있습니다.

3단계: 사용자가 로그인하고 동의하기

인가 요청을 받은 인가 서버는 사용자를 로그인시킵니다. 이미 로그인되어 있다면 세션을 재사용할 수도 있고, 필요한 경우 다중 인증(Multi-Factor Authentication, MFA)을 요구할 수도 있습니다.

여기서 OAuth가 하는 일은 “사용자가 이 클라이언트에 이 scope를 허용했는가”를 확인하는 것입니다. 순수 OAuth 2.0은 인증 프로토콜이 아니라 인가 프로토콜이기 때문에, 내 서비스에 사용자를 로그인시키는 용도로 쓰려면 보통 OpenID Connect(OIDC)를 함께 사용합니다. OIDC를 쓰면 같은 Authorization Code + PKCE 흐름 안에서 ID Token을 받아 “이 사용자가 누구인지”를 검증할 수 있습니다. 이 차이는 구글 OpenID Connect 사용법에서 더 자세히 다뤘습니다.

사용자가 동의하면 인가 서버는 access token을 바로 주지 않습니다. 대신 클라이언트의 redirect URI로 사용자를 돌려보내면서 짧게 사는 authorization code를 붙입니다.

콜백 리다이렉트
HTTP/1.1 302 Found
Location: https://client.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj

이 시점의 code는 아직 access token이 아닙니다. 일회용 교환권에 가깝습니다. 보통 몇 분 안에 만료되고, 한 번 사용하면 다시 쓸 수 없어야 합니다. 같은 code로 두 번 토큰 교환을 시도하면 두 번째 요청은 invalid_grant로 실패하는 것이 정상입니다.

4단계: 콜백에서 state 검증하기

브라우저는 redirect URI로 이동하고, 클라이언트는 콜백 요청을 받습니다. 여기서 첫 번째로 할 일은 state 검증입니다.

클라이언트가 처음 저장해둔 state와 콜백으로 돌아온 state가 같아야 합니다. 다르면 사용자가 직접 시작한 정상 흐름인지 믿을 수 없습니다. 이 경우 code가 함께 와 있더라도 토큰 교환을 하면 안 됩니다.

콜백 URL
https://client.example.com/callback
  ?code=SplxlOBeZQQYbYS6WxSbIA
  &state=af0ifjsldkj

state 검증이 끝나면 클라이언트는 저장해두었던 code_verifier를 꺼냅니다. 이 값은 인가 요청에 실었던 code_challenge의 원본입니다. 만약 이 값을 잃어버렸다면 토큰 교환을 진행할 수 없습니다. SPA에서 페이지 새로고침이나 여러 탭 로그인 때문에 code_verifier를 찾지 못하는 버그가 자주 생기는데, 이때도 대개 invalid_grant로 보입니다.

5단계: code를 토큰으로 교환하기

이제 클라이언트는 토큰 엔드포인트로 POST 요청을 보냅니다. 본문은 application/x-www-form-urlencoded 형식입니다. Authorization Code + PKCE에서 핵심 파라미터는 grant_type, code, redirect_uri, client_id, code_verifier입니다.

토큰 교환 요청
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
&client_id=calendar-client
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

confidential client라면 여기에 클라이언트 인증이 추가됩니다. 예를 들어 서버 사이드 웹앱은 HTTP Basic 인증으로 client_idclient_secret을 보낼 수 있습니다. 반면 SPA나 모바일 앱 같은 public client는 client_secret을 안전하게 보관할 수 없으므로 secret을 쓰면 안 됩니다. 이때 클라이언트를 보호하는 핵심 장치가 PKCE입니다.

인가 서버는 이 요청을 받으면 여러 가지를 확인합니다. 먼저 code가 존재하는지, 만료되지 않았는지, 이미 사용되지 않았는지 확인합니다. 그리고 토큰 요청의 redirect_uri가 인가 요청 때 썼던 값과 같은지 확인합니다. 마지막으로 code_verifier를 SHA-256으로 해시하고 Base64URL로 인코딩해서, 처음 저장해둔 code_challenge와 비교합니다.

모든 검증을 통과하면 access token을 발급합니다.

토큰 응답
{
  "access_token": "eyJhbGciOi...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
  "scope": "calendar.read"
}

access_token은 자원 서버 API를 호출할 때 씁니다. token_typeBearer라면 토큰을 가진 쪽이 곧 권한을 가진 쪽이므로 저장과 전송을 조심해야 합니다. expires_in은 초 단위 유효 기간입니다. 긴 세션이 필요하면 refresh_token이 함께 발급될 수 있는데, 갱신 토큰(refresh token)은 액세스 토큰보다 훨씬 민감합니다. 토큰 갱신 흐름은 OAuth 2.0 엔드포인트 제대로 이해하기의 token endpoint 설명에서 함께 볼 수 있습니다.

6단계: access token으로 API 호출하기

access token을 받았다면 자원 서버에 API를 호출할 수 있습니다. Bearer 토큰은 HTTP Authorization 헤더에 넣습니다. 쿼리 문자열에 토큰을 넣으면 로그, 브라우저 히스토리, Referer 헤더에 새어 나갈 수 있으므로 피해야 합니다.

자원 서버 API 호출
GET /calendar/events HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...

자원 서버는 access token을 검증합니다. 토큰이 JWT라면 서명, 만료 시간, 발급자(issuer), 수신 대상(audience), 스코프(scope)를 자체적으로 확인할 수 있습니다. 불투명 토큰이라면 인가 서버의 introspection endpoint에 물어보는 방식이 필요할 수 있습니다.

검증은 “토큰이 유효한가”에서 끝나지 않습니다. 이 토큰이 지금 호출한 API를 위한 토큰인지, 필요한 scope를 가지고 있는지도 확인해야 합니다. 예를 들어 calendar.read scope만 있는 토큰으로 이벤트를 삭제하려고 하면 자원 서버는 거부해야 합니다. 여러 자원 서버를 다루는 환경이라면 audience 검증도 중요해지는데, 이 부분은 OAuth 2.0 메타데이터와 엔드포인트 동적 발견에서 RFC 8707 Resource Indicators와 함께 다뤘습니다.

state와 PKCE는 무엇이 다를까요?

statecode_verifier는 모두 클라이언트가 만든 무작위 값이라 처음에는 비슷해 보입니다. 하지만 역할은 분명히 다릅니다.

state는 콜백이 내가 시작한 요청의 응답인지 확인합니다. 즉 클라이언트가 브라우저를 통해 돌아온 응답을 믿어도 되는지 판단하는 장치입니다. 주로 CSRF와 로그인 응답 주입 문제를 줄이는 데 쓰입니다.

code_verifier는 토큰 교환 요청을 보낸 쪽이 처음 인가 요청을 시작한 쪽과 같은지 확인합니다. 즉 인가 서버가 code를 access token으로 바꿔줘도 되는지 판단하는 장치입니다. authorization code를 중간에서 빼앗긴 상황을 방어합니다.

둘은 서로 대체할 수 없습니다. PKCE를 쓴다고 state를 빼면 안 되고, state를 쓴다고 PKCE를 생략해도 안 됩니다. 요즘 구현에서는 둘 다 넣는 것이 기본입니다.

자주 터지는 오류들

Authorization Code + PKCE 흐름에서 가장 자주 보는 에러는 invalid_grant입니다. 이 에러는 범위가 넓어서 디버깅이 까다롭습니다. authorization code가 만료되었을 때, 이미 한 번 사용했을 때, 토큰 요청의 redirect_uri가 인가 요청과 다를 때, code_verifier가 처음의 code_challenge와 맞지 않을 때 모두 같은 에러로 보일 수 있습니다.

redirect URI 불일치도 흔합니다. https://client.example.com/callbackhttps://client.example.com/callback/은 다른 URI입니다. 로컬 개발에서 http://localhost:3000/callback으로 등록해두고 실제 요청은 http://127.0.0.1:3000/callback으로 보내도 다르게 취급될 수 있습니다. OAuth 보안 권고인 RFC 9700은 redirect URI를 정확히 비교하는 방향을 강조합니다.

SPA에서 client_secret을 쓰는 실수도 조심해야 합니다. 브라우저에 내려간 secret은 secret이 아닙니다. 소스 코드, 번들, 네트워크 요청에서 모두 노출될 수 있으므로 public client로 등록하고 PKCE를 사용해야 합니다.

토큰 저장 위치도 놓치기 쉽습니다. access token을 URL 쿼리 문자열에 넣거나 오래 남는 저장소에 무심코 보관하면 노출 범위가 커집니다. 가능하다면 access token의 수명을 짧게 유지하고, refresh token을 쓴다면 rotation과 재사용 탐지를 켜는 편이 안전합니다.

마지막으로 OAuth와 로그인을 같은 것으로 생각하는 실수도 많습니다. OAuth access token은 기본적으로 “어떤 API를 호출할 수 있는가”를 나타냅니다. 내 서비스의 로그인에 쓰려면 OpenID Connect의 ID Token 검증까지 포함해서 설계해야 합니다. 소셜 로그인 구현에서는 이 차이를 먼저 분리해두는 편이 안전합니다.

마치며

Authorization Code + PKCE는 복잡해 보이지만, 흐름을 값의 이동으로 보면 단순해집니다. 클라이언트는 먼저 statecode_verifier를 만들고, code_challenge만 인가 요청에 보냅니다. 인가 서버는 로그인과 동의를 처리한 뒤 authorization code를 돌려주고, 클라이언트는 저장해둔 state를 확인한 다음 code_verifier와 함께 code를 토큰으로 교환합니다. 마지막으로 access token을 Authorization: Bearer 헤더에 담아 자원 서버를 호출합니다.

핵심은 브라우저 앞쪽 채널에는 access token과 code_verifier를 노출하지 않는 것입니다. state는 콜백을 보호하고, PKCE는 토큰 교환을 보호합니다. 이 두 장치를 함께 이해하면 OAuth 로그인 구현에서 만나는 invalid_grant, redirect URI 불일치, code 재사용 문제를 훨씬 빠르게 추적할 수 있습니다.

더 자세한 규격은 RFC 6749 - The OAuth 2.0 Authorization Framework, RFC 7636 - Proof Key for Code Exchange, RFC 9700 - Best Current Practice for OAuth 2.0 Security를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord