C.W.K.
Stream
Lesson 04 of 06 · published

OAuth2 with PKCE — 현대 canonical flow

~12 min · auth-security, oauth2, pkce, authorization-code

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"OAuth2 가 2012년 secret 가진 confidential server-side client 위해 설계. 세계가 secret 못 지키는 모바일 앱과 SPA 로 가득. PKCE 가 per-request 증명 추가해서 fix. 2026년에 모든 flow — confidential 이든 public 이든 — 가 PKCE 씀."

OAuth2 가 실제 뭔지

OAuth2 가 delegated authorization protocol. User 가 제 3자 앱한테 자기 대신 resource 접근 권한 부여, 제 3자한테 password 절대 안 공유. 전형적 예: "이 앱이 네 Google 캘린더 읽게 허용." Google 한테 직접 인증; Google 이 앱한테 scoped token 발급; 앱이 token 으로 캘린더 접근.

역할 넷:

  • Resource owner — 너, user.
  • Client — 접근 요청하는 제 3자 앱.
  • Authorization server — user 인증하고 token 발급하는 identity provider (Google, GitHub, Auth0, Anthropic).
  • Resource server — 보호된 resource 보유한 API (Google Calendar API, GitHub API). 자주 auth server 와 같은 조직.

Authorization Code Flow 가 존재하는 이유

Flow 의 일: auth server 에서 client 한테 token 받기, user 의 브라우저 주소창이나 브라우저 history (누설 가능) 에 token 노출 없이.

  1. Client 가 user 브라우저를 ?response_type=code&client_id=...&redirect_uri=...&scope=...&state=... 와 함께 auth server 로 redirect.
  2. User 가 auth server 에 인증 (login, MFA, 뭐든), scope 검토, 동의.
  3. Auth server 가 redirect_uri?code=...&state=... 로 redirect back.
  4. Client 의 BACKEND 가 그 code 받아서 client_secret 과 함께 auth server token endpoint 에 POST, access_token (그리고 보통 refresh_token) 받음.
  5. Client 가 access_token 을 resource server request 에 Authorization: Bearer ... 로 씀.

중간 code 가 브라우저 통해 보내기 안전한 일회용, 단기 placeholder. 실제 token 이 절대 브라우저 주소창 안 만짐.

모바일과 SPA client 문제

위 step 4 가 client_secret 요구 — request 가 정당 client 앱에서 오는 거 증명하는 값, impostor 아님. Server-쪽 앱이 env var 에 저장 (안전). 모바일 앱이 binary 에 embed (binary 가진 누구든 추출). SPA 가 JavaScript 에 ship (URL 가진 누구든 추출).

역사적으로 OAuth2 spec 이 우회로 Implicit Flow 제공: code 교환 완전 건너뛰고 redirect URI fragment 에 token 직접 돌려줌. 근데 브라우저 history, referrer header 통해 token 새고, refresh token 미지원. 은퇴.

PKCE 가 어떻게 fix

PKCE (Proof Key for Code Exchange, RFC 7636) 가 사전 공유 필요 없는 per-request secret 추가:

  1. Redirect 전 client 가 random code_verifier (43-128 글자) 생성하고 code_challenge = base64url(sha256(code_verifier)) 계산.
  2. Client 가 초기 authorization request 에 code_challenge 보냄: ?response_type=code&client_id=...&code_challenge=...&code_challenge_method=S256.
  3. Auth server 가 발급된 code 와 함께 challenge 저장.
  4. Client 가 code 를 token 으로 교환할 때 원래 code_verifier 같이 보냄.
  5. Auth server 가 sha256(code_verifier) == code_challenge 확인. Yes 면 token 발급. No 면 거부.

승리: authorization code 가로채는 공격자가 code_verifier 모르고 교환 못 함 — 원래 client 만 앎, 로컬에서 생성해서. client_secret 불필요.

2026년에 모든 OAuth2 client 가 PKCE 써야. 원래 모바일과 SPA client 용 의도, IETF (RFC 9700, BCP 240) 가 이제 confidential client 한테도 PKCE 권장. 비용 거의 영 (flow 당 sha256 hash 하나) 이고 authorization-code-interception 공격 한 카테고리 제거. 켜; 논쟁 마.

Scope — 세밀한 권한 부여

scope parameter 가 client 가 요청하는 거 나열: scope=read:calendar write:calendar.events. Auth server 가 동의 동안 user 한테 표시 ("이 앱이 원함: 캘린더 읽기, 캘린더 이벤트 생성"). 부여된 scope 가 발급된 token 의 claim 됨; resource server 가 token 쓸 때 강제.

Scope 설계 API-specific. Google API 가 URL-스타일 scope 씀 (https://www.googleapis.com/auth/calendar.readonly); GitHub 가 짧은 이름 씀 (repo, user:email); Stripe 가 OAuth scope 많이 안 씀 — 대부분 통합이 full-account 라서. Convention 골라; 문서화.

OIDC — OAuth2 에 identity 추가

OAuth2 가 authorization, authentication 아님. "이 token 이 캘린더 읽을 권한 있음" 이 user 가 누군지 안 알려줌. OpenID Connect (OIDC) 가 OAuth2 위에 앉아서 response 에 id_token (user identity claim 담은 서명된 JWT) 추가. 대부분 현대 "X 로 sign in" 버튼 (Google, Apple, Microsoft) 이 raw OAuth2 아닌 OIDC 구현. Flow shape 동일; OIDC 가 그냥 id_token 추가.

cwkPippa 의 OAuth 현실

cwkPippa 의 brain adapter 가 provider 셋에 대해 OAuth2 씀: Claude 용 Anthropic OAuth (confidential client, server-쪽), Codex 용 ChatGPT OAuth (confidential client), Gemini 용 Cloud Code Assist OAuth (confidential client). 셋 다 저장된 client_secrets 가진 backend 에서 돌아 — 그 specific 케이스엔 PKCE 불필요, 브라우저-쪽 public client 안 관여라서. cwk-site 의 user auth 가 Supabase 통해 가는데 자체 아래에서 PKCE-flavored OAuth 돌림. Full PKCE flow 가 redirect 보유하는 브라우저 있을 때마다 작용.

Code

Client: redirect 전 verifier + challenge 생성·python
# Client 쪽 — PKCE verifier 와 challenge 생성
import secrets, hashlib, base64

# Step 1: verifier 생성 (random URL-safe string, 43-128 글자)
code_verifier = secrets.token_urlsafe(64)

# Step 2: challenge = base64url(sha256(verifier)) 유도
challenge_bytes = hashlib.sha256(code_verifier.encode('ascii')).digest()
code_challenge  = base64.urlsafe_b64encode(challenge_bytes).rstrip(b'=').decode('ascii')

# Step 3: authorization URL 구성
from urllib.parse import urlencode
auth_url = 'https://auth.example.com/authorize?' + urlencode({
    'response_type':         'code',
    'client_id':             'my-app-id',
    'redirect_uri':          'https://my-app.example.com/callback',
    'scope':                 'read:user write:repo',
    'state':                 secrets.token_urlsafe(16),  # CSRF 보호
    'code_challenge':        code_challenge,
    'code_challenge_method': 'S256',
})
print('브라우저에서 이 URL 열어:', auth_url)
# code_verifier 저장 — 교환 step 에 필요
Code 를 PKCE verifier 써서 access_token 으로 교환·python
# Client 쪽 — authorization code 를 token 으로 교환
import httpx

# Auth server 가 redirect_uri?code=...&state=... 로 redirect 후
# (보낸 state 일치 검증!)

resp = httpx.post(
    'https://auth.example.com/token',
    data={
        'grant_type':   'authorization_code',
        'client_id':    'my-app-id',
        'redirect_uri': 'https://my-app.example.com/callback',
        'code':         'redirect 에서 받은 code',
        'code_verifier': code_verifier,    # <-- 이 flow 시작했다는 증명
        # confidential client 는 client_secret 도 여기 보냄; public client 안 보냄
    },
)
tokens = resp.json()
print(tokens)
# {
#   "access_token":  "eyJhbGciOi...",
#   "token_type":    "Bearer",
#   "expires_in":    3600,
#   "refresh_token": "def50200...",
#   "scope":         "read:user write:repo"
# }
Auth server: token 교환 동안 PKCE verifier validate·python
# Server 쪽 — auth server 가 token 교환 동안 PKCE validate
from fastapi import FastAPI, Form, HTTPException
import hashlib, base64, secrets, time

app = FastAPI()
_codes: dict[str, dict] = {}  # code -> {client_id, code_challenge, redirect_uri, user_id, exp}

@app.post('/token')
async def token(
    grant_type:   str = Form(...),
    client_id:    str = Form(...),
    redirect_uri: str = Form(...),
    code:         str = Form(...),
    code_verifier: str = Form(...),
):
    if grant_type != 'authorization_code':
        raise HTTPException(400, detail='미지원 grant_type')
    record = _codes.pop(code, None)
    if not record or record['exp'] < time.time():
        raise HTTPException(400, detail='무효 혹은 만료된 code')
    if record['client_id'] != client_id or record['redirect_uri'] != redirect_uri:
        raise HTTPException(400, detail='client_id 혹은 redirect_uri 불일치')

    # 핵심 체크: client 가 verifier 알았어?
    derived_challenge = base64.urlsafe_b64encode(
        hashlib.sha256(code_verifier.encode('ascii')).digest()
    ).rstrip(b'=').decode('ascii')
    if derived_challenge != record['code_challenge']:
        raise HTTPException(400, detail='PKCE verifier 불일치')

    # 다 좋음 — access token 발급 (JWT 나 opaque)
    return {
        'access_token':  issue_access_token(record['user_id']),
        'token_type':    'Bearer',
        'expires_in':    3600,
        'refresh_token': issue_refresh_token(record['user_id']),
    }

External links

Exercise

Authorization Code + PKCE flow 가진 작은 OAuth2 auth server FastAPI 에 구현: /authorize (가짜 동의 페이지 render, code_challenge 받음), /token (code_verifier validate, access+refresh 발급). 그 다음 전체 flow 걷는 client 써: verifier+challenge 생성, authorize URL 열기, redirect URL 다시 붙여넣기, code+verifier 를 token 으로 교환, 보호된 /me endpoint 에 access token 써. 보너스: 일부러 잘못된 code_verifier 제출하고 auth server 가 400 돌려주는 거 검증.
Hint
Verifier 생성은 그냥 secrets.token_urlsafe(64); challenge 는 = padding 떼낸 base64url(sha256(verifier)). Auth server 가 각 발급된 code 와 code_challenge 저장; /token 에서 제출된 verifier 에서 유도된 challenge 계산하고 비교. 전체 PKCE 추가가 코드 20줄쯤 — 가치가 복잡도 아니라 spec 이 가졌던 'public client 가 OAuth 안전히 못 씀' 문제 제거.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.