OAuth 2.0 보안: PKCE는 이제 선택이 아닌 필수다 (구현 가이드)

아직도 모바일 앱이나 SPA(Single Page Application)에서 client_secret을 하드코딩하거나, 보안이 취약한 Implicit Flow를 사용하고 있다면 당장 멈춰야 한다. 당신의 인가 코드(Authorization Code)는 가로채질 위험에 처해 있다. OAuth 2.1 초안부터 모든 클라이언트에게 필수로 권장되는 PKCE(Proof Key for Code Exchange)의 정확한 원리와 구현 방법을 정리한다.

PKCE(Proof Key for Code Exchange)란?

OAuth 2.0의 확장 표준(RFC 7636)으로, 인가 코드 요청 시 일회용 비밀번호(Code Verifier)를 생성하여 클라이언트를 검증하는 방식이다. 공격자가 인가 코드를 탈취하더라도, 원본 비밀번호(Verifier)를 알 수 없으므로 액세스 토큰 발급을 차단할 수 있다.

핵심 원리: 수하물 보관표(Claim Tag)의 비유

비유(Analogy): 공항에서 짐을 맡길 때 직원이 당신에게 '보관표(Verifier)'를 주고, 짐에는 '태그(Challenge)'를 붙인다. 나중에 짐을 찾을 때(토큰 교환), 누군가 짐 태그(인가 코드)만 뜯어왔다고 해서 짐을 내어주지 않는다. 반드시 처음에 받은 '보관표'를 제시해야만 짐을 찾을 수 있다.

기존 Authorization Code Flow의 가장 큰 취약점은, "토큰을 달라고 요청한 녀석""처음에 로그인을 요청한 녀석"과 동일한지 확신할 수 없다는 것이었다. 특히 모바일 환경에서는 딥링크(Deep Link)를 통해 다른 악성 앱이 인가 코드를 가로채는 'Interception Attack'이 빈번했다. PKCE는 이 연결 고리를 암호학적으로 검증한다.

실전 구현: S256 방식 적용

PKCE의 핵심은 두 가지 값, code_verifiercode_challenge를 생성하고 검증하는 것이다. 보안을 위해 반드시 S256 해시 알고리즘을 사용해야 한다.

1. Code Verifier 및 Challenge 생성 (Node.js 예제)

표준 crypto 모듈을 사용하여 구현한다. 외부 라이브러리 없이도 충분하다.


const crypto = require('crypto');

// 1. Code Verifier 생성 (랜덤 문자열, 43~128자)
// URL-safe한 문자열이어야 함 (A-Z, a-z, 0-9, -, ., _, ~)
function generateCodeVerifier() {
  return base64URLEncode(crypto.randomBytes(32));
}

// 2. Code Challenge 생성 (Verifier를 SHA256 해싱 후 Base64URL 인코딩)
function generateCodeChallenge(verifier) {
  const hash = crypto.createHash('sha256').update(verifier).digest();
  return base64URLEncode(hash);
}

// Helper: Base64URL 인코딩 (Padding '=' 제거, '+' -> '-', '/' -> '_')
function base64URLEncode(str) {
  return str.toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

// 실행 예시
const verifier = generateCodeVerifier();
const challenge = generateCodeChallenge(verifier);

console.log('Verifier (저장 필수):', verifier);
console.log('Challenge (전송):', challenge);

2. 전체 인증 흐름 (Flow)

  1. 클라이언트(시작): verifier를 생성하여 로컬 스토리지나 메모리에 반드시 저장한다. 그리고 challenge를 생성한다.
  2. 로그인 요청 (GET /authorize): 파라미터에 code_challengecode_challenge_method=S256을 포함하여 인증 서버로 보낸다.
    GET /authorize?response_type=code&...&code_challenge={challenge}&code_challenge_method=S256
  3. 인증 서버: 사용자 로그인 후, challenge 값을 임시 저장하고 인가 코드(Authorization Code)를 발급한다.
  4. 토큰 교환 (POST /token): 클라이언트는 발급받은 코드와 함께 처음에 저장해둔 원본 verifier를 보낸다.
    POST /token (body: code={code}&code_verifier={verifier}...)
  5. 서버 검증: 서버는 받은 verifier를 똑같이 SHA256 해싱하여, 아까 저장해둔 challenge와 일치하는지 확인한다. 일치해야만 토큰을 발급한다.

주의사항: code_challenge_method를 생략하면 많은 서버가 보안에 취약한 plain 방식으로 처리할 수 있다. 반드시 명시적으로 S256을 지정해야 한다.

자주 묻는 질문 (FAQ)

Q. 모바일 앱이 아닌 웹 서비스(Backend)에서도 PKCE를 써야 하나?

A. 그렇다. OAuth 2.1에서는 모든 클라이언트(Public & Confidential)에 PKCE 사용을 의무화하고 있다. 백엔드 서버 간 통신이라 할지라도 CSRF(Cross-Site Request Forgery) 공격 방어 효과가 있으므로 사용하는 것이 모범 사례(Best Practice)다.

Q. Implicit Flow와 PKCE 방식의 차이는?

A. Implicit Flow는 토큰을 URL 해시에 노출시켜 보안에 매우 취약하며, 현재 공식적으로 사용 중단(Deprecated)이 권고된다. SPA(React, Vue 등)에서도 반드시 PKCE가 적용된 Authorization Code Flow를 사용해야 한다.

Q. code_verifier는 어디에 저장해야 안전한가?

A. 브라우저 환경이라면 sessionStorage 혹은 httpOnly 쿠키 등을 활용할 수 있다. 중요한 것은 인가 요청을 보낸 브라우저 탭/세션이 토큰 교환 시점까지 해당 값을 유지하고 있어야 한다는 점이다. 또한, Verifier는 일회용이므로 토큰 교환 후 즉시 파기해야 한다.

OlderNewest

Post a Comment