모바일 앱의 OAuth 로그인에서 인가 서버가 돌려보내는 리다이렉트(myapp://cb?code=...)를 악성 앱이 가로챈다면, 그 코드로 남의 access token을 발급받을 수 있을까? PKCE가 없다면 답은 "그렇다"이고, PKCE가 있다면 "코드는 훔쳐도 소용없다"이다. 이 한 문장의 차이가 어디서 생기는지, RFC 7636을 따라가며 정리했다.
OAuth 2.0 grant type들을 정리하면서 "SPA·모바일은 왜 authorization code grant에 PKCE를 얹어 쓰나"를 한 줄로 넘겼던 적이 있다. 그때 나는 PKCE를 "client_secret이 없는 클라이언트를 위한 secret 대용품" 정도로 이해하고 있었는데, 이 요약이 왜 절반만 맞는지 확인하고 싶었다. PKCE가 정확히 무엇을, 어떻게 막는지 메커니즘을 손으로 따라가 보면 그 오해가 자연스럽게 풀린다.
네이티브·모바일 공개 클라이언트(public client)는 redirect URI로 보통 커스텀 URI 스킴(myapp://cb)을 쓴다. 문제는 OS가 이 스킴을 여러 앱이 등록하는 것을 막지 못한다는 점이다. 악성 앱이 같은 스킴을 선점하면, 인가 서버가 브라우저를 통해 돌려보내는 리다이렉트(= 인가 코드가 실린 단계)를 가로챌 수 있다.
공개 클라이언트는 client_secret이 없다(있더라도 앱 바이너리에서 추출될 수 있어 비밀이라 보기 어렵다). 그래서 공격자는 훔친 코드 + 공개된 client_id만으로 토큰 엔드포인트에서 코드를 access token으로 교환해버릴 수 있다. RFC 7636 §1이 정의하는 "authorization code interception attack"이 바로 이 시나리오다.
[정상] 앱 → /authorize → 로그인 → redirect(code) → 앱 → /token(code) → token
[공격] 앱 → /authorize ─ 로그인 ─ redirect(code) ─╳ 악성앱이 code 가로챔 → /token(code) → token 탈취
PKCE(Proof Key for Code Exchange, "픽시"라고 읽는다)의 발상은 단순하다.
매 인가 요청마다 클라이언트가 즉석에서 만든 1회용 난수의 해시만 인가 요청에 노출하고, 토큰 교환 때 그 난수의 원본을 제시하게 한다.
code_verifier: 클라이언트가 인가 요청 직전에 만드는 고엔트로피 난수 문자열. RFC 7636 §4.1의 ABNF는 43*128unreserved — 즉 43~128자의 URL-safe 문자열이다. §7.1은 최소 256비트 엔트로피를 권장하는데, 32바이트 난수를 base64url 인코딩하면 43자 verifier가 나오는 것이 전형이다.code_challenge: verifier를 변환 메서드(code_challenge_method)로 가공한 값. 두 가지가 있다.plain: code_challenge = code_verifier (변환 없음)S256: code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))여기서 세부가 중요하다. S256에서 SHA-256의 입력은 verifier 문자열의 ASCII 옥텟이고, 출력 32바이트를 base64url(패딩 = 제거)로 인코딩한다. 그래서 S256 challenge는 항상 43자로 고정된다.
(1) 클라이언트: verifier = random(32B); challenge = S256(verifier)
(2) GET /authorize?response_type=code&client_id=...&redirect_uri=...
&code_challenge=<challenge>&code_challenge_method=S256
(3) 인가 서버: 발급하는 authorization code에 (challenge, method)를 서버측에 바인딩해 저장
(4) redirect: myapp://cb?code=<code> ← 여기서 code가 새어도
(5) POST /token grant_type=authorization_code&code=<code>
&redirect_uri=...&client_id=...&code_verifier=<verifier> ← 원본 verifier 제시
(6) 인가 서버 검증:
S256 → BASE64URL(SHA256(ASCII(code_verifier))) == 저장된 challenge ?
같으면 토큰 발급, 다르면 error=invalid_grant (§4.6)
핵심은 바인딩의 방향이다. 인가 요청(2)에는 해시(challenge)만 실려 나가고, 토큰 요청(5)에는 원본(verifier)이 실린다. 이 둘은 서로 다른 HTTP 트랜잭션이다.
인가 코드를 훔친 공격자는 (4)의 리다이렉트만 볼 수 있으니 code는 얻지만, verifier는 (5)의 토큰 요청 — TLS로 보호되고 정상 클라이언트 프로세스 내부에만 존재하는 값 — 에만 있어 얻을 수 없다. SHA-256은 단방향이라 challenge로부터 verifier를 역산할 수도 없다. 결과적으로 인가 코드가 그 코드를 요청한 바로 그 클라이언트 인스턴스에 암호학적으로 묶인다. 이것이 proof-of-possession(소유 증명)이다.
RFC 7636 §4.1–4.2, §4.6을 그대로 옮기면 검증이 결정적(deterministic)임을 손으로 확인할 수 있다. verifier가 정해지면 challenge는 유일하고, 서버는 저장한 challenge와 재계산 값을 바이트 비교만 하면 된다.
// 클라이언트측 생성 (§4.1–4.2)
byte[] octets = new byte[32];
new SecureRandom().nextBytes(octets); // 256비트 엔트로피 (§7.1)
String verifier = Base64.getUrlEncoder().withoutPadding()
.encodeToString(octets); // 43자 URL-safe
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(verifier.getBytes(StandardCharsets.US_ASCII)); // ASCII(verifier)
String challenge = Base64.getUrlEncoder().withoutPadding()
.encodeToString(digest); // S256, 43자 고정
// 서버측 검증 (§4.6) — 저장한 challenge와 재계산 비교
boolean ok = MessageDigest.isEqual(
storedChallenge.getBytes(StandardCharsets.US_ASCII), // 인가 때 저장한 값
sha256Base64Url(receivedVerifier).getBytes(StandardCharsets.US_ASCII)); // 토큰 요청 verifier 재해시
// ok == false → error=invalid_grant
두 가지가 눈으로 확인된다. 첫째, verifier가 같으면 challenge도 같고(SHA-256 결정성), 한 글자만 달라도 눈사태 효과로 완전히 다른 43자가 나와 비교가 실패한다. 둘째, SHA-256 출력 32바이트를 base64url 무패딩으로 인코딩하면 ⌈32/3⌉×4 − 패딩 = 43자로, RFC가 말하는 "S256 challenge는 항상 43자"와 맞아떨어진다.
plain은 challenge == verifier라서, 공격자가 인가 요청까지 관찰할 수 있는 상황(OS 로그, 로컬 프록시 등)이면 challenge를 그대로 verifier로 획득해 방어가 무너진다. S256은 요청에 해시만 보이므로 요청을 관찰당해도 verifier를 복원할 수 없다.
그래서 §7.2는 다운그레이드를 명시적으로 막는다.
따라서 S256 요청에 서버가 에러를 낸다면 그건 "서버 결함"이거나 "MITM이 다운그레이드를 유도 중"이라는 신호로 읽을 수 있다.
PKCE는 리다이렉트로 새어나갈 수 있는 인가 코드를, 그 코드를 요청한 클라이언트 인스턴스에만 동적으로 묶는 1회용 소유 증명이다. 해시는 요청에, 원본은 교환에 — 이 비대칭이 방어의 전부다.
더 파고들 만한 주제 두 가지를 남겨둔다.
state 파라미터(CSRF 방지)와 PKCE의 역할이 어디서 겹치고 갈리는가 — PKCE가 state의 CSRF 방어까지 흡수하는지.nonce(ID token 재생 방지)와 PKCE(인가 코드 바인딩)의 두 축이 겹치는 지점.