PKCE 문제는 보통 이렇게 나타난다. 웹에서는 인가 코드 흐름이 잘 동작하는데, 모바일 SDK를 업그레이드한 뒤 새 기기에서 로그인할 때마다 invalid_grant로 끝나고, 서버 로그에는 /token 요청에 code_verifier가 없거나, 앞서 보낸 challenge와 해시가 맞지 않는 값이 왔다고 남는다. 세션이 캐시된 기기는 계속 동작하므로 테스트에서 놓치기 쉽고, 조사는 대개 ‘어느 값을 해시하고 어느 값을 어디로 보내는가’라는 질문에서 시작한다.
PKCE는 Proof Key for Code Exchange의 약자로, server secret이 없는 클라이언트도 인가 요청의 발신자임을 증명할 수 있게 하는 OAuth 확장이다. RFC 7636이 2015년 모바일·네이티브 앱을 위해 도입했다. RFC 9700(2025년)은 퍼블릭 클라이언트에 PKCE를 필수로, 컨피덴셜 클라이언트에는 권장으로 정했고, OAuth 2.1 초안은 모든 클라이언트에 요구하되 OpenID Connect nonce를 올바르게 구현한다고 인가 서버가 판단할 수 있는 컨피덴셜 클라이언트만 예외로 둔다. 메커니즘 자체는 한 단락이면 설명할 만큼 작지만, 해피 패스 테스트는 통과하면서 실제 클라이언트가 첫 재시도하는 순간 조용히 무너지는 식으로 잘못 구현되기도 쉽다. ZeroTool의 PKCE 생성기는 브라우저 안에서 페어를 만들고, /authorize URL과 /token 교환용 curl을 함께 보여주며, RFC 문자 집합을 벗어난 verifier는 challenge 계산 자체를 거부하고, 로그의 challenge가 맞지 않으면 원인까지 알려 준다.
PKCE가 실제로 지키는 것
표준 OAuth 2.0 인가 코드 흐름은 이렇게 동작한다. 사용자가 클라이언트에서 “Acme로 로그인”을 누르면 https://acme.example/authorize로 client_id와 redirect_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(서버 렌더링) | RFC 9700 §2.1.1은 권장. OAuth 2.1 초안 §7.5.1.1은 클라이언트가 OpenID Connect nonce를 올바르게 구현한다고 서버가 판단할 수 있는 경우를 빼고 필수 | code_verifier와 client_secret 모두 |
마지막 줄이 OAuth 2.1로 옮겨 가는 팀이 놓치기 쉬운 지점이다. 서버 측 client_secret을 가진 Express 백엔드라도 PKCE를 써야 한다. secret은 클라이언트를 인증할 뿐, 훔친 인가 코드를 그 클라이언트의 세션에 주입하는 공격은 막지 못하지만 PKCE는 막는다. 그래서 RFC 9700은 컨피덴셜 클라이언트에도 PKCE를 권장하고, OAuth 2.1 초안은 OpenID Connect nonce라는 한 가지 예외를 빼고 필수로 정하며, 예외에 해당해도 여전히 권장한다.
워크벤치를 한 번 훑어보기
PKCE 생성기를 열면 스크립트가 실행되는 순간 verifier가 렌더링된다. 기본값은 43자다. crypto.getRandomValues로 얻은 32바이트 난수를 base64url로 인코딩한 것으로, RFC 7636 §4.1과 §7.1이 제시하는 방법 그대로다(256비트). 제공자가 길이를 정해 두었다면 길이를 43~128 사이로 바꾼다. verifier 위 줄에 길이와 엔트로피가 표시된다. 새로 생성(또는 Ctrl/⌘+Enter)을 누르면 새 verifier가 나오고 challenge도 즉시 다시 계산된다. 내 앱의 verifier를 붙여 넣으면 RFC 문법으로 한 글자씩 검사해 처음 문제가 되는 문자를 알려 준다.
새로 생성 옆이 방식 선택이며 S256과 plain이 있다. 기본은 S256. RFC 7636 §4.2는 S256을 쓸 수 있는 클라이언트에 S256을 의무화하고 plain은 SHA-256을 계산할 수 없는 클라이언트에만 허용한다. OAuth 2.1 초안 §7.5.2는 plain을 금지했다. 다만 모든 서버가 이를 강제하지는 않는다. 페이수(Feishu)의 인가 코드 발급 API는 plain을 받아 주고, code_challenge_method가 없으면 plain으로 처리한다. S256을 고르고 방식은 항상 명시하자.
code_challenge 카드에 보이는 값이 실제로 /authorize에 실어 보내는 값이다. 거기서 복사한다. verifier를 새로 만들 때마다 값이 바뀐다――새 verifier의 새 해시이기 때문.
아래 세 패널은 각각 역할이 있다. code_challenge 대조는 앱이 실제로 보낸 값을 붙여 넣으면 verifier와 짝이 맞는지, 아니면 흔한 실수(끝의 =, 표준 Base64, 16진수 다이제스트, verifier 그대로, 디코딩한 난수 바이트의 해시) 중 무엇인지 판정한다. 인가 요청 URL은 인가 엔드포인트·client_id·redirect_uri·scope를 넣으면 response_type=code, 현재 challenge, code_challenge_method, 무작위 state, scope에 openid가 있으면 무작위 nonce까지 포함한 완성된 /authorize URL을 만든다. 이걸 브라우저 주소창에 붙여 흐름 1단계를 실행하고, 로그인 후 리다이렉트 URL에서 code를 가져온다.
**토큰 요청(cURL)**은 그에 대응하는 /token 요청을 생성한다. 리다이렉트로 받은 code를 code 칸에 붙여 넣고 명령을 실행한다. code_verifier body 매개변수에 들어가는 값은 verifier 원문――challenge가 아니다. verifier와 challenge가 제대로 짝지어졌고 code가 신선하면 제공자는 토큰을 반환한다. 일치하지 않으면 invalid_grant――프로덕션에서 PKCE가 깨졌을 때와 똑같은 에러다. 먼저 code_challenge 대조로 확인하자.
암호 처리는 정말 몇 줄이면 끝난다
진짜 의미 있는 네 줄은 소리 내어 읽을 만큼 짧다.
// 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은 최소이자 완전히 합법. 이 도구의 기본값도 43자다. RFC 7636 §7.1은 최소 256비트의 엔트로피를 요구하고, 32바이트 난수가 정확히 그만큼이다.
해싱은 SubtleCrypto로 한다. Web Crypto API의 일부이며 페이지가 HTTPS로 제공되면 현대 주요 브라우저는 모두 사용 가능. crypto.subtle을 찾을 수 없으면(흔히 http://로 페이지를 열었을 때) 도구는 실행을 거부하고 인라인 상태 메시지로 HTTPS 전환을 안내한다.
Base64url은 base64의 작은 변형: + → -, / → _, 끝의 = 패딩 제거. RFC 7636이 지정하는 형식이 바로 이것이다. 여기서의 인코딩 실수는 흔한 조용한 버그다. 표준 btoa() 출력에는 +와 /가 들어가고 끝에 =가 붙으므로, 서버가 verifier로 계산한 base64url 해시와 절대 일치하지 않고 토큰 요청은 모호한 에러로 실패한다. 그 값을 code_challenge 대조에 붙여 넣으면 어떤 인코딩 실수인지 도구가 알려 준다.
로그인을 조용히 깨뜨리는 다섯 가지 실수
1. challenge를 /token으로 보내기. /authorize는 challenge, /token은 verifier. 둘 다 43자짜리 base64url 문자열이라 바꿔치기해도 눈으로는 구분 안 된다. 외우기: challenge는 공개적으로 약속한 값, verifier는 자신이 가지고 있었음을 증명하는 값.
2. code_challenge_method 불일치. /authorize에서 S256을 선언했는데 클라이언트 측에서 SHA-256 계산을 잊고 원본 verifier를 challenge로 보냈다면, 서버는 /token에서 받은 verifier를 해시해 “당신이 challenge로 보낸 원본 verifier”와 비교하므로 당연히 일치하지 않는다. method를 빠뜨리는 것은 반대 방향의 같은 실수다. RFC 7636 §4.3에 따라 code_challenge_method가 없으면 plain으로 간주되어, 올바른 S256 challenge가 verifier 원문과 비교된다. method는 challenge와 반드시 함께 보내고, 전역 기본값을 S256으로 두자.
3. 재시도 시 verifier 재사용. 인가 흐름 한 번에 하나의 새 verifier/challenge 쌍. 사용자가 동의 화면을 닫아 /authorize를 재시도한다면 반드시 새 verifier를 만들어야 한다――서버는 중단된 이전 verifier를 기억하고 있을 수 있다.
4. verifier를 엉뚱한 곳에 보관. SPA라면 아래 브라우저 예제처럼 sessionStorage에 두고, 토큰 요청이 끝나면 지운다. verifier는 같은 탭에서 리다이렉트를 한 번 넘기기만 하면 된다. sessionStorage는 그 탭에 속하고 탭을 닫으면 지워진다. localStorage는 로그인 뒤에도 남고 같은 출처의 모든 탭이 공유하므로, 두 탭에서 동시에 로그인하면 서로의 verifier를 덮어쓴다. 어느 쪽도 스크립트 주입은 막지 못한다. 같은 출처에서 실행되는 JavaScript라면 둘 다 읽을 수 있다. 네이티브 앱은 플랫폼의 안전 저장소를 사용한다(iOS Keychain, Android는 Android Keystore 키로 암호화). verifier를 평문으로 디스크에 기록하지 말 것.
5. “S256은 번거롭다”며 plain 선택. 위 다섯 줄이 S256의 완전한 구현이고 OAuth 라이브러리에도 들어 있다. 사양에는 plain이 설 자리가 없다. RFC 7636 §4.2는 SHA-256을 계산할 수 없는 클라이언트에만 허용하고, RFC 9700 §2.1.1은 인가 요청에서 verifier를 드러내지 않는 방식이 현재 S256뿐이라고 설명하며, OAuth 2.1 초안 §7.5.2는 금지한다. 서버마다 처리는 다르다. LINE 로그인은 S256만 받지만, Keycloak은 클라이언트 설정에서 plain을 고를 수 있고 페이수는 method가 없으면 plain으로 처리한다.
주요 제공자에 연결하기
code_challenge 매개변수 모양은 제공자 사이에 동일하다――RFC 7636이 표준화했다. 차이는 등록 절차에만 있다: “Public Client” 또는 “Allow PKCE” 체크 여부, 어떤 redirect_uri 스킴을 허용할지.
Auth0는 문서에서 client secret을 보관할 수 없는 앱(네이티브 앱, 싱글 페이지 앱)용으로 PKCE 인가 코드 흐름을 안내하며, SDK가 매개변수를 대신 붙여 준다. 엔드포인트는 https://YOUR_TENANT.auth0.com/authorize와 https://YOUR_TENANT.auth0.com/oauth/token.
Okta의 Authorization Code with PKCE 가이드는 퍼블릭 클라이언트용 앱 유형인 Native Application 또는 Single-Page Application 통합을 만드는 것으로 시작한다. org 인가 서버를 쓰면 엔드포인트는 https://YOUR_OKTA_DOMAIN/oauth2/v1/authorize와 https://YOUR_OKTA_DOMAIN/oauth2/v1/token.
Keycloak은 클라이언트별 PKCE method 옵션으로 설정한다(Server Administration Guide). 비워 두면 클라이언트가 매개변수를 보낼 때만 PKCE를 적용하고, S256이나 plain을 고르면 그 방식이 필수가 된다. S256을 고른다. 엔드포인트는 realm 패턴 https://YOUR_KEYCLOAK/realms/YOUR_REALM/protocol/openid-connect/{authorize,token}.
Amazon Cognito는 인가 코드 그랜트에서 PKCE를 지원한다. challenge는 인가 엔드포인트 https://YOUR_DOMAIN.auth.REGION.amazoncognito.com/oauth2/authorize로, verifier는 /oauth2/token으로 보낸다.
Spotify는 Authorization Code with PKCE를 모바일 앱, 싱글 페이지 앱 등 client secret을 안전하게 보관할 수 없는 앱에 권장하는 흐름으로 안내한다. 매개변수는 다른 곳과 같다.
세 가지 구현 스니펫
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 최소, RFC 7636 예제 출력 정확, 영어 전용. 2026-10-01에 시험해 보니 입력을 검증하지 않아, 42자, + / =가 섞인 verifier, 빈칸 모두 challenge가 나왔다. Ping Identity의 PKCE Code Generator는 브라우저 안에서 생성하지만 verifier 칸이 읽기 전용이라 내 앱의 verifier를 대조할 수 없다. oauth.com/playground는 상호작용성이 강하지만 자체 테스트 issuer에 대한 라운드트립을 통째로 구성한다――학습용으로는 훌륭하지만 내 제공자를 디버그하기에는 무겁다.
ZeroTool의 도구가 메우는 빈자리는 다음과 같다: 생성기와 붙여넣기 가능한 /token curl을 직접 묶어 실제 제공자에서 교환을 재현하게 해주고, 붙여 넣은 verifier를 RFC 문법으로 검증하며, 로그의 challenge가 맞지 않는 원인을 알려 주고, 동일한 인터페이스를 네 언어로 제공한다. verifier는 페이지 로드마다 새로 생성되며 localStorage, sessionStorage, URL 어디에도 쓰이지 않는다――persistence policy가 disabled로 설정돼 있고, 페이지는 분석·광고 스크립트도 불러오지 않는다. 디버그 세션 사이에 탭을 새로 고치면 이전 verifier는 사라진다――OAuth helper에 기대하는 “무상태” 동작과 일치한다.
더 읽을거리
- RFC 7636 — Proof Key for Code Exchange — 원전
- OAuth 2.1 초안 — OpenID Connect nonce를 쓰는 컨피덴셜 클라이언트(§7.5.1.1)를 제외하고 PKCE를 필수로 하며
plain을 금지(§7.5.2) - RFC 9700 — OAuth 2.0 Security Best Current Practice — 퍼블릭 클라이언트는 PKCE 필수, 컨피덴셜 클라이언트는 권장(§2.1.1)
- Web Crypto API: SubtleCrypto.digest — MDN 한국어판 SHA-256 호출 참고
- JWT 디코더 — PKCE 교환 성공 후
/token이 반환한 ID 토큰 또는 JWT 형식의 access token 확인 - JWT 생성기 — 리소스 서버 테스트용으로 직접 HS256 토큰 발행
- 해시 생성기 — SHA-256·SHA-384·SHA-512를 수동 계산하고 싶을 때