PKCE로 Authorization Code 흐름 안전하게 보호하기
OAuth 2.0의 Authorization Code 흐름을 처음 구현해보면, 어딘가 찜찜한 부분이 있습니다. 서버가 있는 웹 애플리케이션이라면 client secret을 안전하게 숨겨둘 수 있는데, 모바일 앱이나 SPA(Single Page Application)는 어디에 숨겨야 할까요? 🤔 APK를 디컴파일하거나 브라우저 개발자 도구를 열면 그대로 노출되니, 사실상 비밀이 아닌 셈입니다.
PKCE(Proof Key for Code Exchange, “픽시”라고 읽습니다)는 바로 이 문제를 해결하기 위해 만들어진 보안 확장입니다. OAuth 2.1에서는 모든 클라이언트에 PKCE가 필수가 되었을 정도로 중요한데요. 이번 글에서는 PKCE가 왜 필요한지, 어떻게 동작하는지, 그리고 어떻게 구현하면 되는지 차근차근 살펴보겠습니다. OAuth 2.0 자체가 낯설다면 OAuth 2.0 쉽게 이해하기를 먼저 읽어보시면 좋겠습니다.
Authorization Code 흐름의 빈틈
PKCE를 이해하려면 먼저 어떤 공격을 막으려고 하는지부터 알아야 합니다.
일반적인 Authorization Code 흐름을 떠올려 볼게요.
사용자가 동의 화면에서 “허용”을 누르면 인가 서버가 클라이언트의 redirect URI로 authorization code를 돌려보냅니다.
클라이언트는 이 authorization code를 자신의 client_id, client_secret과 함께 토큰 엔드포인트에 제출해서 access token을 받아냅니다.
여기서 client_id는 “내가 누구인지”를 밝히는 식별자이고, client_secret은 “정말 그 클라이언트가 맞다”는 것을 증명하는 비밀번호 역할을 합니다.
서버 사이드 웹 애플리케이션이라면 secret을 환경 변수에 잘 숨겨두면 되지만, 모바일 앱과 SPA는 사정이 다릅니다.
배포된 코드를 누구나 들여다볼 수 있는 환경이라 secret을 클라이언트에 박아두는 순간 공개된 값이 되고 마는데요.
이런 종류의 클라이언트를 OAuth 용어로 공개 클라이언트(public client) 라고 부릅니다.
공개 클라이언트가 secret 없이 동작한다면 다음과 같은 시나리오가 가능해집니다.
악성 앱이 같은 기기에 설치되어 있다고 가정해볼게요.
정상 앱이 OS의 커스텀 URL 스킴(myapp://callback)을 통해 redirect를 받도록 등록되어 있는데, 악성 앱이 같은 스킴을 가로채도록 등록할 수 있다면 어떻게 될까요?
인가 서버가 발급한 authorization code가 악성 앱에 전달되고, 악성 앱은 그 code로 토큰 엔드포인트를 호출해 access token을 가져갑니다.
이것이 바로 authorization code interception attack이라고 불리는 공격입니다.
PKCE의 핵심 아이디어
PKCE는 “이 토큰 요청을 보낸 사람이, 처음 인가 요청을 보낸 사람과 같은 사람인가?”를 검증하는 장치입니다. 방식은 의외로 단순합니다.
요청을 시작할 때 클라이언트가 무작위 비밀값 하나를 생성합니다. 이 값을 그대로 가지고 있되, 해시한 값만 인가 서버에 미리 보내둡니다. 나중에 토큰을 교환할 때 원본 비밀값을 같이 보내면, 인가 서버가 해시해서 처음 받은 값과 비교합니다. 일치하면 같은 클라이언트가 맞다고 판단하는 것이죠.
이 무작위 비밀값을 code_verifier, 해시한 값을 code_challenge라고 부릅니다. 이름이 거창해 보이지만 본질은 “일회용 비밀번호를 클라이언트가 스스로 만들어서 자기 자신을 증명한다”는 아이디어입니다.
악성 앱이 authorization code를 가로채더라도 원본 code_verifier는 정상 앱의 메모리에만 있으므로 토큰 교환에 실패합니다.
그래서 PKCE를 쓰면 secret이 없어도 공개 클라이언트가 안전하게 동작할 수 있습니다.
code_verifier와 code_challenge 만들기
스펙(RFC 7636)이 정의하는 규칙은 비교적 간단합니다.
code_verifier는 43자 이상 128자 이하의 무작위 문자열입니다.
사용 가능한 문자는 A-Z, a-z, 0-9, -, ., _, ~로 제한됩니다.
충분한 엔트로피(최소 256비트 권장)를 가지도록 암호학적으로 안전한 난수 생성기를 사용해야 합니다.
code_challenge는 code_verifier를 변환한 값인데, 두 가지 방식 중 하나를 선택할 수 있습니다.
plain 방식은 code_verifier를 그대로 code_challenge로 사용합니다.
구현이 간단하지만 인가 요청이 가로채진다면 code_verifier도 같이 노출되므로 보안 효과가 거의 없습니다.
S256 방식은 code_verifier를 SHA-256으로 해시한 뒤 Base64 URL-safe 인코딩(패딩 제거)으로 변환합니다.
해시 함수의 단방향성 덕분에 code_challenge만 가지고 code_verifier를 역산할 수 없으므로 훨씬 안전합니다.
실제로 OAuth 2.1과 대부분의 인가 서버는 S256만 허용합니다.
PKCE 인가 흐름 단계별
전체 흐름을 단계별로 따라가 볼게요.
먼저 클라이언트가 인가 요청을 보내기 전에 code_verifier를 생성하고, 이를 SHA-256으로 해시해서 code_challenge를 만듭니다.
code_verifier는 클라이언트의 메모리(또는 세션 저장소)에만 보관합니다.
그다음 평소처럼 인가 서버의 /authorize 엔드포인트로 사용자를 보내는데, 이때 추가 파라미터로 code_challenge와 code_challenge_method=S256을 함께 전달합니다.
GET /authorize?
response_type=code
&client_id=my-app
&redirect_uri=myapp%3A%2F%2Fcallback
&scope=profile
&state=xyz
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
인가 서버는 이 code_challenge를 발급한 authorization code와 함께 저장해둡니다.
사용자가 동의하면 평소처럼 redirect URI로 authorization code가 돌아옵니다.
마지막으로 클라이언트가 토큰 엔드포인트에 code를 제출할 때, 처음에 생성해둔 code_verifier를 함께 보냅니다.
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AbCdEf123456
&redirect_uri=myapp%3A%2F%2Fcallback
&client_id=my-app
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
인가 서버는 받은 code_verifier를 SHA-256으로 해시해서 처음 저장해둔 code_challenge와 비교합니다.
일치하면 access token을 발급하고, 일치하지 않으면 요청을 거부합니다.
이제 악성 앱이 authorization code만 가로챈다고 해도 code_verifier를 모르므로 토큰 교환에 실패하게 됩니다.
자바스크립트로 직접 구현하기
내장 모듈만으로 간단히 구현할 수 있습니다.
먼저 Node.js 환경에서는 node:crypto를 사용합니다.
import crypto from "node:crypto";
function base64UrlEncode(buffer) {
return buffer
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
export function generateCodeVerifier() {
// 32바이트 난수 → Base64 URL 인코딩 시 약 43자
return base64UrlEncode(crypto.randomBytes(32));
}
export function generateCodeChallenge(verifier) {
const hash = crypto.createHash("sha256").update(verifier).digest();
return base64UrlEncode(hash);
}
실행해보면 다음과 같은 값이 나옵니다.
const verifier = generateCodeVerifier();
const challenge = generateCodeChallenge(verifier);
console.log({ verifier, challenge });
// {
// verifier: 'mZ_qC2x...43자 이상의 무작위 문자열',
// challenge: '43자 길이의 SHA-256 해시(Base64 URL)'
// }
Bun에서는 별도 import 없이 Bun.CryptoHasher와 표준 Web Crypto API의 crypto.getRandomValues()를 조합하면 더 간결해집니다.
function base64UrlEncode(bytes) {
return Buffer.from(bytes)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
export function generateCodeVerifier() {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return base64UrlEncode(bytes);
}
export function generateCodeChallenge(verifier) {
const hash = new Bun.CryptoHasher("sha256").update(verifier).digest();
return base64UrlEncode(hash);
}
브라우저에서는 Buffer가 없으니 Web Crypto API와 btoa만으로 같은 일을 할 수 있습니다.
async function generateCodeChallenge(verifier) {
const data = new TextEncoder().encode(verifier);
const hash = await crypto.subtle.digest("SHA-256", data);
return btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
code_verifier는 인가 요청부터 토큰 교환까지 유지되어야 하므로, SPA라면 sessionStorage에, 모바일 앱이라면 메모리나 OS의 안전한 저장소(iOS Keychain, Android Keystore)에 보관합니다.
localStorage는 다른 탭에서도 접근할 수 있어 권장되지 않습니다.
두 저장소의 차이가 궁금하다면 자바스크립트 Web Storage API 글을 참고해보세요.
OAuth 2.1에서의 PKCE 필수화
원래 PKCE는 모바일 앱과 SPA 같은 공개 클라이언트만을 위한 보완책이었습니다. 하지만 OAuth 2.1 드래프트에서는 모든 OAuth 클라이언트에 PKCE를 필수로 요구합니다. 서버 사이드 웹 애플리케이션처럼 client secret이 안전하게 보호되는 경우에도 마찬가지입니다.
왜 그럴까요? client secret만으로는 막을 수 없는 종류의 공격이 있기 때문입니다. 예를 들어 인가 서버와 클라이언트 사이의 어딘가에서 authorization code가 노출되는 상황(서버 로그, 프록시, 리퍼러 헤더 등)에서, PKCE는 코드만으로는 토큰을 교환할 수 없게 만들어 추가 방어선을 제공합니다.
또한 redirect URI mix-up 공격이나 cross-site request forgery 변형 공격을 방어하는 데도 도움이 됩니다. “필요한 곳에만 쓰자”가 아니라 “기본으로 쓰자”는 방향으로 기준이 바뀐 것이죠.
OAuth 2.1을 활용하는 최신 사례가 궁금하다면 MCP Authentication을 참고해보세요. Model Context Protocol은 OAuth 2.1과 PKCE를 그대로 사용해서 AI 에이전트를 위한 인증 스펙을 정의하고 있습니다.
마치며
이름은 거창하지만 PKCE가 하는 일은 결국 “클라이언트가 일회용 비밀번호를 스스로 만들어 증명한다”가 전부입니다.
무작위 code_verifier를 만들고, SHA-256으로 해시한 code_challenge를 미리 보낸 뒤, 토큰 교환 시점에 원본을 제출해서 검증받는 거죠.
이 작은 장치 하나로 authorization code 탈취 공격을 막을 수 있습니다.
새로운 OAuth 클라이언트를 만든다면 클라이언트 종류에 상관없이 PKCE를 적용하는 것을 권장합니다.
대부분의 OAuth 라이브러리(oauth4webapi, openid-client 등)가 PKCE를 기본으로 지원하므로, 직접 구현하는 대신 검증된 라이브러리를 사용하는 편이 안전합니다.
관련해서 구글 OAuth 사용법이나 구글 OpenID Connect 사용법 같은 실전 예제도 함께 보시면 도움이 됩니다.
PKCE에 대한 더 깊은 내용은 RFC 7636 - Proof Key for Code Exchange에서 확인하실 수 있습니다.
This work is licensed under
CC BY 4.0