어제까지 멀쩡하던 인가 코드 흐름을 인계받았다. 주말에 동료가 모바일 SDK를 업그레이드했더니 새 기기마다 로그인이 invalid_grant로 실패한다. 웹은 멀쩡하다. 서버 로그를 보면 /token 호출에 code_verifier가 빠져 있다. 새 SDK changelog를 열어 보니 상위 라이브러리가 조용히 보관하던 client secret을 PKCE로 교체했고, 마이그레이션은 “무감각하게” 진행될 예정이었다. 모바일 담당이 눈치 못 챈 이유는 테스트 단말에 세션이 캐시돼 있었기 때문. 팀이 회귀를 발견하기까지 세 시간 동안 “SHA-256은 verifier냐 challenge냐”를 두고 책상에서 다투었다.

PKCE는 Proof Key for Code Exchange의 약자로, server secret이 없는 클라이언트도 인가 요청의 발신자임을 증명할 수 있게 하는 OAuth 확장이다. RFC 7636이 2015년 모바일·네이티브 앱을 위해 도입했고, OAuth 2.1은 confidential 클라이언트를 포함한 모든 클라이언트에 PKCE를 필수화했다. 메커니즘 자체는 한 단락이면 설명할 만큼 작지만, 해피 패스 테스트는 통과하면서 실제 클라이언트가 첫 재시도하는 순간 조용히 무너지는 식으로 잘못 구현되기도 쉽다. ZeroTool의 PKCE 생성기는 브라우저 안에서 페어를 만들고, /authorize URL과 /token 교환용 curl을 함께 보여주며, RFC 문자 집합을 벗어난 verifier는 challenge 계산 자체를 거부한다.

PKCE가 실제로 지키는 것

표준 OAuth 2.0 인가 코드 흐름은 이렇게 동작한다. 사용자가 클라이언트에서 “Acme로 로그인”을 누르면 https://acme.example/authorizeclient_idredirect_uri를 붙여 리다이렉트한다. 로그인 후 Acme가 다시 redirect_uri로 보내며 쿼리에 불투명한 code를 실어 보낸다. 클라이언트 백엔드는 https://acme.example/token에서 그 code를 access token과 교환할 때 Acme와 미리 공유한 client_secret으로 자신을 인증한다.

약점은 리다이렉트 구간이다. 모바일에서는 누구든 myapp://callback 핸들러를 등록할 수 있다. SPA에서는 code가 window.location에 잠깐 노출되어 URL을 읽을 수 있는 모든 확장이 볼 수 있다. 공격자가 클라이언트보다 먼저 code를 가로채고 client_id까지 알고 있다면, 토큰 교환을 대신 완료할 수 있다.

PKCE가 이 구멍을 막는다. 클라이언트는 /authorize로 보내기 전에 code_verifier라는 무작위 시크릿을 만들고 SHA-256으로 해시한 뒤 base64url로 인코딩해 code_challenge를 만든다. /authorize 요청에 싣는 것은 challenge다――verifier가 아니다. 서버는 발급한 code에 challenge를 묶어 기록한다. 클라이언트가 code를 들고 /token에 올 때 원래의 code_verifier도 함께 보낸다. 서버는 그것을 다시 해시해 기록된 challenge와 비교한다. code를 가로챈 공격자는 verifier를 한 번도 본 적이 없으므로 두 번째 요청을 위조할 수 없다.

verifier는 비밀, challenge는 공개 약속이다. 둘이 한 쌍이 되어 code 가로채기 공격이 무력화된다――교환의 두 번째 단계를 완료하려면 네트워크에 한 번도 흐른 적 없는 지식이 필요하기 때문이다.

오늘 당장 PKCE가 필요한 다섯 가지 흐름

클라이언트 유형PKCE가 필요한 이유/token 인증
네이티브 모바일(iOS / Android)client_secret을 안전히 보관할 수 없음. RFC 7636의 원래 대상code_verifier만――Public Client
SPA(React / Vue / SvelteKit 하이드레이트)브라우저에서 같은 문제. JS의 모든 것은 확장과 DevTools에 노출code_verifier만――Public Client
로컬 callback 서버를 두는 CLI 도구http://127.0.0.1:PORT 루프백 리다이렉트는 로컬 임의 프로세스에 가로채일 수 있음code_verifier만――Public Client
데스크톱 앱(Electron · 네이티브)URL 핸들러가 시스템 단위로 등록 가능code_verifier
Confidential Web App(서버 렌더링)OAuth 2.1은 모든 클라이언트에 PKCE 요구. 서버에 secret이 있어도 다층 방어로 사용code_verifierclient_secret 모두

마지막 줄이 OAuth 2.0에서 2.1로 올라갈 때 많은 팀이 놓치는 함정이다. 서버 측 client_secret을 가진 Express 백엔드라도 2.1 시대에는 PKCE를 함께 써야 한다. 위협 모델이 “secret은 새어 나갈 수 있다”고 가정하기 때문. PKCE는 그 비밀에 의존하지 않는 두 번째 보험이 된다.

워크벤치를 한 번 훑어보기

PKCE 생성기를 열면 스크립트가 실행되는 순간 verifier가 렌더링된다. 기본값은 RFC 7636 §4.1이 정한 [A-Z a-z 0-9 - . _ ~] 문자 집합에서 무작위로 뽑은 64자. 재생성을 누르면 새 verifier와 같은 프레임에서 다시 계산된 challenge가 즉시 나타난다.

그 아래 Challenge 방식 행에 두 개 pill이 있다. S256이 기본 선택. plain pill이 남아 있는 이유는 SHA-256을 계산할 수 없는 클라이언트를 위해 RFC 7636이 허용했기 때문이다. 그러나 현대의 주요 OAuth 제공자는 plain을 기본적으로 거부하고, OAuth 2.1은 신규 도입을 금지한다. S256을 선택하고 다른 쪽은 잊어버려도 된다.

Code Challenge 카드에 보이는 값이 실제로 /authorize에 실어 보내는 값이다. 거기서 복사한다. 재생성할 때마다 끝 자리가 바뀐다――새 verifier의 새 해시이기 때문.

하단의 <details> 패널 두 개는 복사 가능한 미리보기다. Authorization URL preview는 인증 엔드포인트·client_id·redirect_uri를 끼워 넣는 템플릿. 결과는 response_type=code, 현재 challenge, code_challenge_method=S256, 자리표시자 state, scope=openid profile을 포함한 완성된 /authorize URL. 이걸 브라우저 주소창에 붙여 흐름 1단계를 재현하고, 로그인 후 리다이렉트 URL에서 code를 가져온다.

**Token exchange (cURL)**은 그에 대응하는 /token 요청을 생성한다. code_verifier body 매개변수에 들어가는 값은 페이지가 생성한 난수――challenge가 아니다. AUTHORIZATION_CODE_FROM_REDIRECT를 실제 얻은 값으로 바꿔 명령을 실행한다. verifier와 challenge가 제대로 짝지어졌고 code가 신선하면 제공자는 access token을 반환한다. 일치하지 않으면 invalid_grant――프로덕션에서 PKCE가 깨졌을 때와 똑같은 에러, 이 워크벤치가 디버그하려는 바로 그 실패 모드다.

암호 처리는 정말 몇 줄이면 끝난다

진짜 의미 있는 네 줄은 소리 내어 읽을 만큼 짧다.

// 1. 무작위 verifier 생성
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const verifier = base64url(bytes);   // 32바이트 → 43자

// 2. 해시해서 challenge로
const data = new TextEncoder().encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
const challenge = base64url(new Uint8Array(hash));   // 32바이트 → 43자

function base64url(bytes) {
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
}

이렇게 만들어낸 verifier는 항상 43자다――32바이트를 패딩 없이 base64로 표현하면 정확히 43자가 된다. RFC 7636이 43~128자를 허용하므로 43은 최소이자 완전히 합법. ZeroTool이 64자를 기본으로 하는 이유는 약간 더 많은 엔트로피가 저렴하기도 하고, 일부 제공자가 “너무 짧은 토큰”을 이상하게 거부하기 때문이다.

해싱은 SubtleCrypto로 한다. Web Crypto API의 일부이며 페이지가 HTTPS로 제공되면 현대 주요 브라우저는 모두 사용 가능. crypto.subtle을 찾을 수 없으면(흔히 http://로 페이지를 열었을 때) 도구는 실행을 거부하고 인라인 상태 메시지로 HTTPS 전환을 안내한다.

Base64url은 base64의 작은 변형: +-, /_, 끝의 = 패딩 제거. RFC 7636이 이 형식을 지정하고, 사양을 따르는 제공자는 이 형식만 받는다. 여기서의 인코딩 실수가 가장 흔한 조용한 버그다. 표준 btoa()+/를 포함한 base64를 만들기 때문에 challenge 문자 집합을 엄격히 검증하는 제공자는 요청을 가차 없이 거부한다――에러 메시지로는 뭐가 잘못됐는지 알기 어렵다.

로그인을 조용히 깨뜨리는 다섯 가지 실수

1. challenge를 /token으로 보내기. /authorize는 challenge, /token은 verifier. 둘 다 43자짜리 base64url 문자열이라 바꿔치기해도 눈으로는 구분 안 된다. 내가 디버그한 모든 PKCE 장애 티켓의 결론은 결국 이거였다. 외우기: challenge는 공개적으로 약속한 값, verifier는 자신이 가지고 있었음을 증명하는 값.

2. code_challenge_method 불일치. /authorize에서 S256을 선언했는데 클라이언트 측에서 SHA-256 계산을 잊고 원본 verifier를 challenge로 보냈다면, 서버는 /token에서 받은 verifier를 해시해 “당신이 challenge로 보낸 원본 verifier”와 비교하므로 당연히 일치하지 않는다. method를 양쪽 요청에 일관되게 연결하든지, 아예 전역에서 S256을 기본으로 두고 다시 보지 마라.

3. 재시도 시 verifier 재사용. 인가 흐름 한 번에 하나의 새 verifier/challenge 쌍. 사용자가 동의 화면을 닫아 /authorize를 재시도한다면 반드시 새 verifier를 만들어야 한다――서버는 중단된 이전 verifier를 기억하고 있을 수 있다.

4. verifier 보관이 허술함. SPA라면 localStorage가 현실적인 선택. verifier 수명은 수 분으로 짧고, 위협 모델상으로도 당신의 JS를 통제할 수 있는 공격자는 무엇이든 할 수 있다고 가정한다. 네이티브 앱은 플랫폼의 안전 저장소를 사용한다(iOS Keychain, Android EncryptedSharedPreferences). verifier를 평문으로 디스크에 기록하지 말 것.

5. “S256은 번거롭다”며 plain 선택. 위 다섯 줄이 S256의 완전한 구현. 모든 OAuth 라이브러리에 이미 내장돼 있다. Okta·Auth0·Keycloak·Cognito·Spotify·Google 같은 제공자는 신규 client 등록 시 plain을 거부한다. 2026년에 plain이 옳은 선택인 상황은 존재하지 않는다.

주요 제공자에 연결하기

code_challenge 매개변수 모양은 제공자 사이에 동일하다――RFC 7636이 표준화했다. 차이는 등록 절차에만 있다: “Public Client” 또는 “Allow PKCE” 체크 여부, 어떤 redirect_uri 스킴을 허용할지.

Auth0는 PKCE를 투명하게 다룬다. 앱을 Native 또는 Single Page Application으로 등록하고 Authorization Code Grant를 활성화하면 Auth0가 자동으로 code_challenge를 기대한다. 엔드포인트는 https://YOUR_TENANT.auth0.com/authorizehttps://YOUR_TENANT.auth0.com/oauth/token.

Okta는 앱의 General Settings 탭에서 Use PKCE를 켜야 한다. 엔드포인트는 https://YOUR_OKTA_DOMAIN/oauth2/v1/authorizehttps://YOUR_OKTA_DOMAIN/oauth2/v1/token. client_secret을 가진 Confidential Web App도 OAuth 2.1 가이드를 따라 code_verifier를 함께 보내야 한다.

Keycloak은 OpenID Connect 클라이언트 설정에서 client별로 PKCE를 활성화한다. Proof Key for Code Exchange Code Challenge MethodS256으로 설정. 엔드포인트는 realm 패턴 https://YOUR_KEYCLOAK/realms/YOUR_REALM/protocol/openid-connect/{authorize,token}.

AWS Cognito는 Public Client(secret 없음)에 대해 PKCE를 지원한다. Hosted UI는 https://YOUR_DOMAIN.auth.us-west-2.amazoncognito.com/login, token 엔드포인트는 /oauth2/token.

Spotify는 백엔드 없는 Authorization Code Flow에서 PKCE를 요구한다. 자세한 내용은 Authorization Code with PKCE Flow. 콘솔에 별도의 “Enable PKCE” 토글이 없고, 매개변수만 보내면 된다.

세 가지 구현 스니펫

Python, requests 사용:

import base64, hashlib, secrets, requests

verifier = secrets.token_urlsafe(64)[:64]
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()

# 1. 사용자를 /authorize로 보냄(challenge 포함)
# 2. redirect_uri에서 code 수신
# 3. 교환:
response = requests.post(
    "https://acme.example/token",
    data={
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": "https://yourapp.example/callback",
        "client_id": "YOUR_CLIENT_ID",
        "code_verifier": verifier,
    },
)

JavaScript / TypeScript(브라우저):

const bytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = base64url(bytes);
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(hash));

sessionStorage.setItem('pkce_verifier', verifier);

const authUrl = `https://acme.example/authorize?` + new URLSearchParams({
  response_type: 'code',
  client_id: 'YOUR_CLIENT_ID',
  redirect_uri: 'https://yourapp.example/callback',
  code_challenge: challenge,
  code_challenge_method: 'S256',
  state: crypto.randomUUID(),
});
location.href = authUrl;

function base64url(bytes: Uint8Array) {
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

Bash, code 수신 후 /token 단계:

curl -X POST https://acme.example/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=$AUTHORIZATION_CODE" \
  -d "redirect_uri=https://yourapp.example/callback" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "code_verifier=$CODE_VERIFIER"

브라우저 완결 생성기에 자리가 있는 이유

공개된 PKCE 생성기 대부분은 이미 JS에서 암호 처리를 완결한다――그 부분은 어렵지 않다. 차이는 도구가 주변에 무엇을 함께 제공하느냐다.

tonyxu-io.github.io/pkce-generator는 사실상의 레퍼런스 구현이다. UI 최소, 출력 정확, 영어 전용. 모두가 북마크해 둔 그것. Ping Identity의 PKCE Code Generator는 같은 도구에 Ping 브랜딩을 얹은 것. oauth.com/playground는 상호작용성이 강하지만 자체 테스트 issuer에 대한 라운드트립을 통째로 구성한다――학습용으로는 훌륭하지만 내 제공자를 디버그하기에는 무겁다. 어느 것도 /token 단계의 curl을 주지 않고, 한국어·일본어·중국어 어디서도 동작하지 않는다.

ZeroTool의 도구가 메우는 빈자리는 다음과 같다: 생성기와 붙여넣기 가능한 /token curl을 직접 묶어 실제 제공자에서 교환을 재현하게 해주고, verifier 문자 집합을 실시간 검증하며, 동일한 인터페이스를 네 언어로 제공한다. verifier는 페이지 로드마다 새로 생성되며 localStorage에 결코 쓰이지 않는다――persistence policy가 disabled로 설정돼 있다. 디버그 세션 사이에 탭을 새로 고치면 이전 verifier는 사라진다――OAuth helper에 기대하는 “무상태” 동작과 일치한다.

더 읽을거리