본문 바로가기
C.W.K.
Stream
Lesson 04 of 06 · published

OAuth 2.0과 PKCE — 비밀값을 숨길 수 없는 클라이언트

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

Level 0HTTP 입문자
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"OAuth 2.0은 공개 클라이언트와 비밀 클라이언트를 모두 다루지만, 배포된 앱 안의 client_secret은 비밀로 남을 수 없어. PKCE는 권한 부여 요청을 뒤의 코드 교환과 묶어, 코드를 가로챈 주체가 토큰을 받지 못하게 해. 이제 권한 부여 코드 흐름에는 클라이언트 종류와 관계없이 PKCE를 적용하는 게 기본이야."

OAuth 2.0은 권한 위임 프레임워크야

OAuth 2.0은 권한 위임을 통해 사용자가 제3자 앱에 제한된 리소스 접근 권한을 위임하되 비밀번호는 공유하지 않게 해. 대표적인 예가 "이 앱이 Google 캘린더를 읽도록 허용"하는 흐름이야. 사용자는 Google에서 직접 인증하고 동의하며, 앱은 허용된 범위의 액세스 토큰으로 Calendar API를 호출해.

네 가지 역할이 있어:

  • 리소스 소유자 — 보호된 리소스에 대한 접근을 허가할 수 있는 주체로, 보통 사용자야.
  • 클라이언트 — 리소스 소유자의 권한을 위임받아 API 접근을 요청하는 애플리케이션이야.
  • 권한 부여 서버 — 사용자를 인증하고 동의를 받아 토큰을 발급해. Google, GitHub, Auth0 같은 제공자가 여기에 해당해.
  • 리소스 서버 — Google Calendar API나 GitHub API처럼 보호된 리소스를 제공하고 액세스 토큰을 검사해. 권한 부여 서버와 같은 조직이 운영하는 경우가 많아.

권한 부여 코드 흐름을 쓰는 이유

권한 부여 코드 흐름은 액세스 토큰을 리디렉션 URL에 직접 노출하지 않고, 앞 채널에서 받은 짧은 코드와 뒤 채널의 토큰 교환을 분리해.

  1. 클라이언트가 사용자 브라우저를 ?response_type=code&client_id=...&redirect_uri=...&scope=...&state=...와 함께 권한 부여 서버로 리디렉션해.
  2. 사용자는 권한 부여 서버에서 로그인과 MFA 같은 인증을 마치고 요청된 범위를 검토한 뒤 동의해.
  3. 권한 부여 서버는 브라우저를 redirect_uri?code=...&state=...로 돌려보내.
  4. 클라이언트는 권한 부여 코드를 토큰 엔드포인트에 POST해 access_token과, 정책에 따라 refresh_token을 받아. 비밀 클라이언트는 이때 자체 인증도 수행하고, 공개 클라이언트는 client_secret을 비밀 자격 증명으로 사용할 수 없어.
  5. 클라이언트는 액세스 토큰을 리소스 서버 요청의 Authorization: Bearer ...에 담아 사용해.

중간의 code는 수명이 짧고 한 번만 쓸 수 있는 교환권이야. URL에는 코드가 노출되므로 redirect URI를 정확히 등록·검증하고 코드 재사용을 막아야 하지만, 실제 액세스 토큰을 리디렉션 URL에 싣는 것보다 노출 범위를 줄여 줘.

모바일 앱과 SPA는 비밀 클라이언트가 아니야

서버에서 실행되는 비밀 클라이언트는 client_secret을 보호된 설정에 보관하고 토큰 엔드포인트에서 자신을 인증할 수 있어. 반면 모바일 앱 실행 파일이나 SPA의 JavaScript에 같은 값을 넣으면 배포본을 받은 누구나 추출할 수 있으므로, 그 값은 클라이언트 인증용 비밀로 기능하지 못해.

과거에는 암시적 흐름으로 코드 교환을 생략하고 액세스 토큰을 리디렉션 URI 조각에 직접 반환하기도 했어. 하지만 토큰이 브라우저 앞 채널과 실행 환경에 더 넓게 노출되고, 응답 주입과 토큰 탈취를 막기 어려워 현재 보안 지침에서는 권장하지 않아. 권한 부여 코드 흐름과 PKCE가 공개 클라이언트의 표준 선택이야.

PKCE가 코드 교환을 묶는 방식

PKCE(코드 교환용 증명 키, RFC 7636)는 사전 공유 비밀값 대신 요청마다 새 검증값을 만들어 사용해:

  1. 리디렉션 전에 클라이언트가 무작위 code_verifier (43-128 글자)를 만들고 code_challenge = base64url(sha256(code_verifier))를 계산해.
  2. 초기 권한 부여 요청에 code_challenge를 보내: ?response_type=code&client_id=...&code_challenge=...&code_challenge_method=S256.
  3. 권한 부여 서버는 발급할 코드에 이 챌린지를 연결해 저장해.
  4. 클라이언트는 코드를 토큰으로 교환할 때 원래 code_verifier도 함께 보내.
  5. 권한 부여 서버는 sha256(code_verifier) == code_challenge인지 확인해. 일치할 때만 토큰을 발급해.

권한 부여 코드를 가로챈 공격자는 원래 클라이언트가 로컬에서 만든 code_verifier를 알 수 없으므로 코드를 토큰으로 교환하기 어려워져. PKCE는 공개 클라이언트에 client_secret을 만들어 주는 기능이 아니라, 권한 부여 요청을 시작한 클라이언트가 코드 교환을 마치도록 묶는 장치야. 비밀 클라이언트의 자체 인증을 대체하지도 않아.

권한 부여 코드 흐름에는 PKCE를 기본으로 적용해. PKCE는 모바일 앱과 SPA의 코드 가로채기 위험을 막기 위해 시작됐고, 현재 OAuth 2.0 보안 모범 사례인 RFC 9700은 비밀 클라이언트에도 사용을 권고해. 구현 비용은 작지만 PKCE 하나로 state, nonce, 정확한 redirect URI 검증, TLS, 안전한 토큰 저장까지 대신할 수는 없어. 필요한 방어를 겹쳐 적용해야 해.

범위로 위임 권한을 제한해

scope 매개변수는 클라이언트가 요청하는 권한을 나열해: scope=read:calendar write:calendar.events. 권한 부여 서버는 동의 화면에서 요청 범위를 보여 주고, 승인된 범위를 토큰 또는 서버 쪽 토큰 기록에 연결해. 리소스 서버는 각 API 호출에서 실제로 허용된 범위를 검사해야 해.

범위 이름은 API마다 달라. Google API는 https://www.googleapis.com/auth/calendar.readonly처럼 URL 형태를 사용하고, GitHub은 repouser:email 같은 짧은 이름을 사용해. 이름보다 중요한 건 최소 권한이 가능하도록 범위를 설계하고, 각 엔드포인트가 필요한 범위를 문서화하며, 서버에서 일관되게 집행하는 일이야.

OIDC — OAuth 2.0 위에 신원 계층 더하기

OAuth 2.0의 중심은 권한 부여이지 사용자 로그인 결과를 애플리케이션에 표준화해 전달하는 일이 아니야. OpenID Connect(OIDC)는 OAuth 2.0 위에 인증 계층을 정의하고 id_token이라는 서명형 JWT로 사용자 신원 클레임을 전달해. 현대의 "Google로 로그인", "Apple로 로그인", "Microsoft로 로그인"은 대개 OIDC를 사용해. 단순히 토큰 하나를 더 받는 데서 끝나지 않고, openid 범위와 nonce, issuer·audience·서명·만료 검증 같은 OIDC 규칙을 따라야 해.

cwkPippa의 OAuth 경계

cwkPippa의 브레인 어댑터는 각 제공자가 공식 지원하는 인증 방식을 따르고, OAuth 2.0을 사용하는 연결에서는 백엔드가 리디렉션과 토큰 교환을 맡아. 공개 클라이언트인지 비밀 클라이언트인지, 제공자가 PKCE를 요구하거나 지원하는지에 따라 흐름을 정확히 구성해야 해. cwk-site의 사용자 인증은 Supabase를 통해 처리되며, 브라우저 리디렉션과 세션 교환의 세부 보안은 해당 SDK와 제공자 설정을 함께 따라. 브라우저가 등장한다고 모든 인증이 자동으로 PKCE가 되는 건 아니고, 실제 권한 부여 요청과 토큰 교환에 PKCE 매개변수가 연결돼 있어야 해.

Code

클라이언트: 리디렉션 전에 검증자와 챌린지 만들기·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 에 필요
PKCE 검증자로 권한 부여 코드를 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"
# }
권한 부여 서버: 토큰 교환에서 PKCE 검증하기·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

FastAPI로 권한 부여 코드와 PKCE를 지원하는 작은 OAuth 2.0 권한 부여 서버를 만들어봐. /authorize는 모의 동의 화면을 보여 주고 code_challenge를 받아 일회용 코드를 발급하며, /token은 code_verifier를 검증한 뒤 액세스 토큰과 갱신 토큰을 반환해야 해. 클라이언트에서는 검증자와 챌린지를 만들고, 권한 부여 URL을 연 뒤, 돌아온 리디렉션 URL에서 코드와 state를 확인해 토큰으로 교환해. 마지막으로 액세스 토큰으로 보호된 /me를 호출해봐. 보너스로 잘못된 code_verifier를 제출해 서버가 400을 반환하는지 확인해.
Hint
검증자는 secrets.token_urlsafe(64)로 만들 수 있고, 챌린지는 base64url(sha256(verifier))에서 = 패딩을 제거해 계산해. 권한 부여 서버는 코드를 발급할 때 code_challenge와 client_id, redirect_uri, 만료 시각을 함께 저장해야 해. /token에서는 넘어온 검증자로 챌린지를 다시 계산하고 안전하게 비교해. 실제 구현에서는 코드 검증과 사용 완료 표시를 원자적으로 처리해 재사용과 경합도 막아야 해.

Progress

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

댓글 0

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

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